610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
/**
|
||
* HealthKit 数据同步服务
|
||
*
|
||
* 实现 HealthKit 与服务端之间的双向数据同步
|
||
* - 从 HealthKit 同步身高、体重、出生日期到服务端
|
||
* - 从服务端同步身高、体重、出生日期到 HealthKit
|
||
* - 防止循环同步的机制
|
||
*/
|
||
|
||
import {
|
||
fetchActiveEnergyBurned,
|
||
fetchBasalEnergyBurned,
|
||
fetchCompleteSleepData,
|
||
fetchHourlyExerciseMinutesForDate,
|
||
fetchHourlyStandHoursForDate,
|
||
fetchOxygenSaturation,
|
||
fetchPersonalHealthData,
|
||
fetchSmartHRVData,
|
||
saveHeight,
|
||
saveWeight
|
||
} from '@/utils/health';
|
||
import AsyncStorage from '@/utils/kvStore';
|
||
import { convertHrvToStressIndex } from '@/utils/stress';
|
||
import dayjs from 'dayjs';
|
||
import { DailyHealthDataDto, updateDailyHealthData } from './users';
|
||
|
||
// 同步状态存储键
|
||
const SYNC_STATUS_KEY = '@healthkit_sync_status';
|
||
const SYNC_LOCK_KEY = '@healthkit_sync_lock';
|
||
const DAILY_HEALTH_SYNC_KEY = '@daily_health_sync_status';
|
||
|
||
// 同步状态类型
|
||
interface SyncStatus {
|
||
lastSyncTime: number;
|
||
lastSyncDirection: 'healthkit_to_server' | 'server_to_healthkit' | null;
|
||
lastSyncData: {
|
||
height?: number;
|
||
weight?: number;
|
||
birthDate?: string;
|
||
};
|
||
}
|
||
|
||
// 每日健康数据同步状态
|
||
interface DailyHealthSyncStatus {
|
||
lastSyncTime: number;
|
||
lastSyncDate: string; // YYYY-MM-DD
|
||
data: DailyHealthDataDto;
|
||
}
|
||
|
||
// 同步锁(防止并发同步)
|
||
let syncLock = false;
|
||
|
||
/**
|
||
* 获取同步锁
|
||
*/
|
||
async function acquireSyncLock(): Promise<boolean> {
|
||
if (syncLock) {
|
||
console.log('同步操作正在进行中,跳过');
|
||
return false;
|
||
}
|
||
|
||
// 检查持久化的锁(防止应用重启后的并发)
|
||
const persistedLock = await AsyncStorage.getItem(SYNC_LOCK_KEY);
|
||
if (persistedLock) {
|
||
const lockTime = parseInt(persistedLock);
|
||
const now = Date.now();
|
||
// 如果锁超过5分钟,认为是过期锁,可以清除
|
||
if (now - lockTime < 5 * 60 * 1000) {
|
||
console.log('检测到持久化同步锁,跳过');
|
||
return false;
|
||
} else {
|
||
console.log('清除过期的同步锁');
|
||
await AsyncStorage.removeItem(SYNC_LOCK_KEY);
|
||
}
|
||
}
|
||
|
||
syncLock = true;
|
||
await AsyncStorage.setItem(SYNC_LOCK_KEY, Date.now().toString());
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 释放同步锁
|
||
*/
|
||
async function releaseSyncLock(): Promise<void> {
|
||
syncLock = false;
|
||
await AsyncStorage.removeItem(SYNC_LOCK_KEY);
|
||
}
|
||
|
||
/**
|
||
* 获取上次同步状态
|
||
*/
|
||
async function getLastSyncStatus(): Promise<SyncStatus | null> {
|
||
try {
|
||
const status = await AsyncStorage.getItem(SYNC_STATUS_KEY);
|
||
if (status) {
|
||
return JSON.parse(status);
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('获取同步状态失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存同步状态
|
||
*/
|
||
async function saveSyncStatus(status: SyncStatus): Promise<void> {
|
||
try {
|
||
await AsyncStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status));
|
||
} catch (error) {
|
||
console.error('保存同步状态失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断数据是否需要同步(比较数据是否有变化)
|
||
*/
|
||
function shouldSyncData(
|
||
currentData: { height?: number; weight?: number; birthDate?: string },
|
||
lastSyncData: { height?: number; weight?: number; birthDate?: string }
|
||
): boolean {
|
||
// 身高变化(允许1cm误差)
|
||
if (currentData.height && lastSyncData.height) {
|
||
if (Math.abs(currentData.height - lastSyncData.height) > 1) {
|
||
return true;
|
||
}
|
||
} else if (currentData.height !== lastSyncData.height) {
|
||
return true;
|
||
}
|
||
|
||
// 体重变化(允许0.1kg误差)
|
||
if (currentData.weight && lastSyncData.weight) {
|
||
if (Math.abs(currentData.weight - lastSyncData.weight) > 0.1) {
|
||
return true;
|
||
}
|
||
} else if (currentData.weight !== lastSyncData.weight) {
|
||
return true;
|
||
}
|
||
|
||
// 出生日期变化
|
||
if (currentData.birthDate !== lastSyncData.birthDate) {
|
||
// 出生日期通常不会变,但仍然检查
|
||
if (currentData.birthDate && lastSyncData.birthDate) {
|
||
const date1 = dayjs(currentData.birthDate).format('YYYY-MM-DD');
|
||
const date2 = dayjs(lastSyncData.birthDate).format('YYYY-MM-DD');
|
||
if (date1 !== date2) {
|
||
return true;
|
||
}
|
||
} else if (currentData.birthDate || lastSyncData.birthDate) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 从 HealthKit 同步数据到服务端
|
||
*
|
||
* @param updateUserProfile - 更新用户资料的函数(从 Redux userSlice)
|
||
* @param currentUserProfile - 当前用户资料(用于 diff 比较)
|
||
* @returns 是否成功同步
|
||
*/
|
||
export async function syncHealthKitToServer(
|
||
updateUserProfile: (data: {
|
||
height?: number;
|
||
weight?: number;
|
||
birthDate?: number; // Unix timestamp in seconds
|
||
}) => Promise<any>,
|
||
currentUserProfile?: {
|
||
height?: string | number;
|
||
weight?: string | number;
|
||
birthDate?: string | number;
|
||
}
|
||
): Promise<boolean> {
|
||
console.log('=== 开始从 HealthKit 同步数据到服务端 ===');
|
||
|
||
// 获取同步锁
|
||
const acquired = await acquireSyncLock();
|
||
if (!acquired) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 获取上次同步状态
|
||
const lastSync = await getLastSyncStatus();
|
||
|
||
// 检查是否刚刚从服务端同步过(防止循环)
|
||
if (lastSync?.lastSyncDirection === 'server_to_healthkit') {
|
||
const timeSinceLastSync = Date.now() - lastSync.lastSyncTime;
|
||
// 如果2分钟内刚从服务端同步过,跳过本次同步
|
||
if (timeSinceLastSync < 2 * 60 * 1000) {
|
||
console.log('刚从服务端同步过数据,跳过 HealthKit -> 服务端同步');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 从 HealthKit 获取数据
|
||
const healthData = await fetchPersonalHealthData();
|
||
console.log('从 HealthKit 获取的数据:', healthData);
|
||
|
||
// 检查是否有数据需要同步
|
||
if (!healthData.height && !healthData.weight && !healthData.dateOfBirth) {
|
||
console.log('HealthKit 中没有个人健康数据,跳过同步');
|
||
return false;
|
||
}
|
||
|
||
// 准备当前 HealthKit 数据
|
||
const currentHealthKitData = {
|
||
height: healthData.height || undefined,
|
||
weight: healthData.weight || undefined,
|
||
birthDate: healthData.dateOfBirth || undefined
|
||
};
|
||
|
||
// 1. 首先与服务端当前数据进行比较(如果提供了)
|
||
if (currentUserProfile) {
|
||
// 转换服务端数据格式以便比较
|
||
const serverData = {
|
||
height: currentUserProfile.height ?
|
||
(typeof currentUserProfile.height === 'string' ?
|
||
parseFloat(currentUserProfile.height) :
|
||
currentUserProfile.height) :
|
||
undefined,
|
||
weight: currentUserProfile.weight ?
|
||
(typeof currentUserProfile.weight === 'string' ?
|
||
parseFloat(currentUserProfile.weight) :
|
||
currentUserProfile.weight) :
|
||
undefined,
|
||
birthDate: currentUserProfile.birthDate ?
|
||
(typeof currentUserProfile.birthDate === 'number' ?
|
||
new Date(currentUserProfile.birthDate * 1000).toISOString() :
|
||
currentUserProfile.birthDate) :
|
||
undefined
|
||
};
|
||
|
||
const needsSyncWithServer = shouldSyncData(currentHealthKitData, serverData);
|
||
if (!needsSyncWithServer) {
|
||
console.log('HealthKit 数据与服务端数据一致,跳过同步');
|
||
// 更新同步状态(即使没有同步,也记录检查时间)
|
||
await saveSyncStatus({
|
||
lastSyncTime: Date.now(),
|
||
lastSyncDirection: 'healthkit_to_server',
|
||
lastSyncData: currentHealthKitData
|
||
});
|
||
return false;
|
||
}
|
||
console.log('检测到 HealthKit 数据与服务端数据存在差异,需要同步');
|
||
}
|
||
|
||
// 2. 然后与上次同步的数据进行比较
|
||
if (lastSync?.lastSyncData) {
|
||
const needsSync = shouldSyncData(currentHealthKitData, lastSync.lastSyncData);
|
||
|
||
if (!needsSync) {
|
||
console.log('数据与上次同步时一致,跳过同步');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 准备同步到服务端的数据
|
||
const serverData: {
|
||
height?: number;
|
||
weight?: number;
|
||
birthDate?: number;
|
||
} = {};
|
||
|
||
if (healthData.height) {
|
||
serverData.height = healthData.height;
|
||
}
|
||
|
||
if (healthData.weight) {
|
||
serverData.weight = healthData.weight;
|
||
}
|
||
|
||
if (healthData.dateOfBirth) {
|
||
// 将 ISO 字符串转换为 Unix 时间戳(秒)
|
||
const timestamp = new Date(healthData.dateOfBirth).getTime() / 1000;
|
||
serverData.birthDate = Math.floor(timestamp);
|
||
}
|
||
|
||
// 同步到服务端
|
||
console.log('准备同步到服务端的数据:', serverData);
|
||
await updateUserProfile(serverData);
|
||
console.log('成功同步数据到服务端');
|
||
|
||
// 保存同步状态
|
||
await saveSyncStatus({
|
||
lastSyncTime: Date.now(),
|
||
lastSyncDirection: 'healthkit_to_server',
|
||
lastSyncData: {
|
||
height: healthData.height || undefined,
|
||
weight: healthData.weight || undefined,
|
||
birthDate: healthData.dateOfBirth || undefined
|
||
}
|
||
});
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('从 HealthKit 同步数据到服务端失败:', error);
|
||
return false;
|
||
} finally {
|
||
// 释放同步锁
|
||
await releaseSyncLock();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从服务端同步数据到 HealthKit
|
||
*
|
||
* @param serverData - 服务端的用户数据
|
||
* @returns 是否成功同步
|
||
*/
|
||
export async function syncServerToHealthKit(
|
||
serverData: {
|
||
height?: number;
|
||
weight?: number;
|
||
birthDate?: number | string; // Unix timestamp (seconds) or ISO string
|
||
}
|
||
): Promise<boolean> {
|
||
console.log('=== 开始从服务端同步数据到 HealthKit ===');
|
||
|
||
// 获取同步锁
|
||
const acquired = await acquireSyncLock();
|
||
if (!acquired) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 获取上次同步状态
|
||
const lastSync = await getLastSyncStatus();
|
||
|
||
// 检查是否刚刚从 HealthKit 同步过(防止循环)
|
||
if (lastSync?.lastSyncDirection === 'healthkit_to_server') {
|
||
const timeSinceLastSync = Date.now() - lastSync.lastSyncTime;
|
||
// 如果2分钟内刚从 HealthKit 同步过,跳过本次同步
|
||
if (timeSinceLastSync < 2 * 60 * 1000) {
|
||
console.log('刚从 HealthKit 同步过数据,跳过 服务端 -> HealthKit 同步');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 准备要同步的数据
|
||
const syncData: {
|
||
height?: number;
|
||
weight?: number;
|
||
birthDate?: string;
|
||
} = {};
|
||
|
||
if (serverData.height) {
|
||
// 确保身高是数字类型
|
||
syncData.height = typeof serverData.height === 'string'
|
||
? parseFloat(serverData.height)
|
||
: serverData.height;
|
||
}
|
||
|
||
if (serverData.weight) {
|
||
// 确保体重是数字类型
|
||
syncData.weight = typeof serverData.weight === 'string'
|
||
? parseFloat(serverData.weight)
|
||
: serverData.weight;
|
||
}
|
||
|
||
if (serverData.birthDate) {
|
||
// 如果是时间戳,转换为 ISO 字符串
|
||
if (typeof serverData.birthDate === 'number') {
|
||
syncData.birthDate = new Date(serverData.birthDate * 1000).toISOString();
|
||
} else {
|
||
syncData.birthDate = serverData.birthDate;
|
||
}
|
||
}
|
||
|
||
console.log('准备同步到 HealthKit 的数据:', syncData);
|
||
|
||
// 检查数据是否有变化
|
||
if (lastSync?.lastSyncData) {
|
||
const needsSync = shouldSyncData(syncData, lastSync.lastSyncData);
|
||
|
||
if (!needsSync) {
|
||
console.log('数据未变化,跳过同步');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 同步到 HealthKit
|
||
const results: boolean[] = [];
|
||
|
||
if (syncData.height) {
|
||
console.log('同步身高到 HealthKit:', syncData.height, 'cm');
|
||
const success = await saveHeight(syncData.height);
|
||
results.push(success);
|
||
if (success) {
|
||
console.log('身高同步成功');
|
||
} else {
|
||
console.error('身高同步失败');
|
||
}
|
||
}
|
||
|
||
if (syncData.weight) {
|
||
// 确保体重值是数字类型
|
||
const weightValue = typeof syncData.weight === 'string'
|
||
? parseFloat(syncData.weight)
|
||
: syncData.weight;
|
||
|
||
console.log('同步体重到 HealthKit:', weightValue, 'kg');
|
||
const success = await saveWeight(weightValue);
|
||
results.push(success);
|
||
if (success) {
|
||
console.log('体重同步成功');
|
||
} else {
|
||
console.error('体重同步失败');
|
||
}
|
||
}
|
||
|
||
// 注意:出生日期在 HealthKit 中是只读的,无法写入
|
||
if (syncData.birthDate) {
|
||
console.log('注意:出生日期在 HealthKit 中是只读的,无法同步');
|
||
}
|
||
|
||
// 如果至少有一项同步成功,则认为同步成功
|
||
const success = results.length > 0 && results.some(r => r);
|
||
|
||
if (success) {
|
||
// 保存同步状态
|
||
await saveSyncStatus({
|
||
lastSyncTime: Date.now(),
|
||
lastSyncDirection: 'server_to_healthkit',
|
||
lastSyncData: syncData
|
||
});
|
||
|
||
console.log('成功从服务端同步数据到 HealthKit');
|
||
}
|
||
|
||
return success;
|
||
} catch (error) {
|
||
console.error('从服务端同步数据到 HealthKit 失败:', error);
|
||
return false;
|
||
} finally {
|
||
// 释放同步锁
|
||
await releaseSyncLock();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除同步状态(用于测试或重置)
|
||
*/
|
||
export async function clearSyncStatus(): Promise<void> {
|
||
try {
|
||
await AsyncStorage.removeItem(SYNC_STATUS_KEY);
|
||
await AsyncStorage.removeItem(SYNC_LOCK_KEY);
|
||
syncLock = false;
|
||
console.log('已清除同步状态');
|
||
} catch (error) {
|
||
console.error('清除同步状态失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取同步状态信息(用于调试)
|
||
*/
|
||
export async function getSyncStatusInfo(): Promise<SyncStatus | null> {
|
||
return getLastSyncStatus();
|
||
}
|
||
|
||
/**
|
||
* 同步每日健康数据报表到服务端
|
||
* @param waterIntake - 当日饮水量(从应用内部获取,因为 HealthKit 可能不包含应用内记录)
|
||
*/
|
||
export async function syncDailyHealthReport(waterIntake?: number): Promise<boolean> {
|
||
console.log('=== 开始同步每日健康报表 ===');
|
||
|
||
try {
|
||
const today = new Date();
|
||
const dateStr = dayjs(today).format('YYYY-MM-DD');
|
||
|
||
// 1. 获取各项健康数据
|
||
// 并行获取以提高性能
|
||
const [
|
||
activeEnergy,
|
||
basalEnergy,
|
||
sleepData,
|
||
exerciseMinutesData,
|
||
standHoursData,
|
||
oxygenSaturation,
|
||
hrvData
|
||
] = await Promise.all([
|
||
// 卡路里
|
||
fetchActiveEnergyBurned({
|
||
startDate: dayjs(today).startOf('day').toISOString(),
|
||
endDate: dayjs(today).endOf('day').toISOString()
|
||
}),
|
||
// 基础代谢
|
||
fetchBasalEnergyBurned({
|
||
startDate: dayjs(today).startOf('day').toISOString(),
|
||
endDate: dayjs(today).endOf('day').toISOString()
|
||
}),
|
||
// 睡眠数据 (需要完整数据来获取分钟数)
|
||
fetchCompleteSleepData(today),
|
||
// 锻炼分钟数 (按小时聚合)
|
||
fetchHourlyExerciseMinutesForDate(today),
|
||
// 站立小时 (按小时聚合)
|
||
fetchHourlyStandHoursForDate(today),
|
||
// 血氧
|
||
fetchOxygenSaturation({
|
||
startDate: dayjs(today).startOf('day').toISOString(),
|
||
endDate: dayjs(today).endOf('day').toISOString()
|
||
}),
|
||
// HRV (用于计算压力)
|
||
fetchSmartHRVData(today)
|
||
]);
|
||
|
||
// 2. 数据处理与计算
|
||
|
||
// 计算总锻炼分钟数
|
||
const totalExerciseMinutes = exerciseMinutesData.reduce((sum, item) => sum + item.minutes, 0);
|
||
|
||
// 计算总站立时间 (分钟) - 注意 HealthKit 返回的是小时是否有站立,我们这里估算每小时站立1分钟或者直接用小时数
|
||
// 根据 API 要求 "standingMinutes",HealthKit 的 standHours 是指有多少个小时有站立活动
|
||
// 通常 Apple Watch 判定一小时内站立至少1分钟即计为1个站立小时
|
||
// 为了符合 API 语义,我们这里统计有多少个小时达标,转换成分钟可能不太准确,但 API 字段叫 minutes
|
||
// 策略:如果有 HealthKit 数据,我们用 达标小时数 * 60 作为估算,或者直接传小时数?
|
||
// 文档说 "standingMinutes: 站立时间(分钟)"。
|
||
// HealthKit 的 appleStandHours 是 count,比如 12。
|
||
// 如果我们传 12 分钟显然不对。如果我们传 12 * 60 = 720 分钟也不太对,因为并不是站了这么久。
|
||
// 实际上 Apple 的 Stand Hours 是 "Hours with >1 min standing".
|
||
// 这里我们统计有多少小时是有站立的,并乘以一个系数?或者直接传小时数让后端理解?
|
||
// 鉴于字段名是 minutes,我们统计所有有站立的小时数。
|
||
// 实际上,我们应该直接使用 HealthKit 的 appleStandTime (如果可用) 或者通过 appleStandHours 估算。
|
||
// 这里的 fetchHourlyStandHoursForDate 返回的是每小时是否有站立(0或1)。
|
||
const standHoursCount = standHoursData.filter(h => h.hasStood > 0).length;
|
||
// 暂时策略:将小时数转换为分钟数传递,虽然这代表的是跨度
|
||
const standingMinutes = standHoursCount * 60;
|
||
|
||
// 计算睡眠分钟数
|
||
const sleepMinutes = sleepData ? Math.round(sleepData.totalSleepTime) : 0;
|
||
|
||
// 计算压力值
|
||
let stressLevel = 0;
|
||
if (hrvData && hrvData.value > 0) {
|
||
const stressIndex = convertHrvToStressIndex(hrvData.value);
|
||
if (stressIndex !== null) {
|
||
stressLevel = stressIndex;
|
||
}
|
||
}
|
||
|
||
// 3. 构建 DTO
|
||
const healthData: DailyHealthDataDto = {
|
||
date: dateStr,
|
||
// 只有当数据有效时才包含字段
|
||
...(waterIntake !== undefined && { waterIntake }),
|
||
...(totalExerciseMinutes > 0 && { exerciseMinutes: Math.round(totalExerciseMinutes) }),
|
||
...(activeEnergy > 0 && { caloriesBurned: Math.round(activeEnergy) }),
|
||
...(standingMinutes > 0 && { standingMinutes }),
|
||
...(basalEnergy > 0 && { basalMetabolism: Math.round(basalEnergy) }),
|
||
...(sleepMinutes > 0 && { sleepMinutes }),
|
||
...(oxygenSaturation !== null && oxygenSaturation > 0 && { bloodOxygen: oxygenSaturation }),
|
||
...(stressLevel > 0 && { stressLevel })
|
||
};
|
||
|
||
console.log('准备同步每日健康数据:', healthData);
|
||
|
||
// 4. 检查是否需要同步 (与上次同步的数据比较)
|
||
const lastSyncStatusStr = await AsyncStorage.getItem(DAILY_HEALTH_SYNC_KEY);
|
||
if (lastSyncStatusStr) {
|
||
const lastSyncStatus: DailyHealthSyncStatus = JSON.parse(lastSyncStatusStr);
|
||
|
||
// 如果是同一天,检查数据差异
|
||
if (lastSyncStatus.lastSyncDate === dateStr) {
|
||
const lastData = lastSyncStatus.data;
|
||
const isDifferent =
|
||
healthData.waterIntake !== lastData.waterIntake ||
|
||
healthData.exerciseMinutes !== lastData.exerciseMinutes ||
|
||
healthData.caloriesBurned !== lastData.caloriesBurned ||
|
||
healthData.standingMinutes !== lastData.standingMinutes ||
|
||
healthData.basalMetabolism !== lastData.basalMetabolism ||
|
||
healthData.sleepMinutes !== lastData.sleepMinutes ||
|
||
healthData.bloodOxygen !== lastData.bloodOxygen ||
|
||
healthData.stressLevel !== lastData.stressLevel;
|
||
|
||
if (!isDifferent) {
|
||
console.log('每日健康数据无变化,跳过同步');
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. 调用 API
|
||
if (Object.keys(healthData).length > 1) { // 至少包含 date 以外的一个字段
|
||
await updateDailyHealthData(healthData);
|
||
console.log('每日健康数据同步成功');
|
||
|
||
// 6. 保存同步状态
|
||
const newSyncStatus: DailyHealthSyncStatus = {
|
||
lastSyncTime: Date.now(),
|
||
lastSyncDate: dateStr,
|
||
data: healthData
|
||
};
|
||
await AsyncStorage.setItem(DAILY_HEALTH_SYNC_KEY, JSON.stringify(newSyncStatus));
|
||
return true;
|
||
} else {
|
||
console.log('没有有效的健康数据需要同步');
|
||
return false;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('同步每日健康报表失败:', error);
|
||
return false;
|
||
}
|
||
} |