HealthKitHostApiImpl.ets 8.12 KB
import { HealthActivityGoal, HealthAuthorization, HealthKitHostApi, HealthWorkoutDataPoint, Result } from '../pigeon/HealthKitApi';
import common from '@ohos.app.ability.common';
import { bundleManager, Want } from '@kit.AbilityKit';
import { productViewManager } from '@kit.AppGalleryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { healthService, healthStore } from '@kit.HealthServiceKit';

/**
 * Bridges Flutter's health authorization flow to Health Service Kit.
 */
export class HealthKitHostApiImpl extends HealthKitHostApi {
  private static readonly HEALTH_APP_LINK = 'huaweischeme://healthapp/home/main';
  private static readonly HEALTH_APP_BUNDLE_NAME = 'com.huawei.hmos.health';
  private readonly context: common.UIAbilityContext;
  // Keep this list aligned with the health metrics requested by the Flutter app.
  private readonly authorizationRequest: healthStore.AuthorizationRequest = {
    readDataTypes: [
      healthStore.healthDataTypes.DAILY_ACTIVITIES,
      healthStore.healthDataTypes.HEART_RATE,
      healthStore.healthDataTypes.HEART_RATE_VARIABILITY,
      healthStore.healthDataTypes.BLOOD_OXYGEN_SATURATION,
      healthStore.healthDataTypes.STRESS,
      healthStore.healthDataTypes.SLEEP_RECORD,
      healthStore.healthDataTypes.BODY_TEMPERATURE,
      healthStore.healthDataTypes.SKIN_TEMPERATURE,
      healthStore.healthDataTypes.WORKOUT,
    ],
    writeDataTypes: [],
  };

  constructor(context: common.UIAbilityContext) {
    super();
    this.context = context;
  }

  getHealthServerAuthUrl(result: Result<string>): void {
    // Health Service Kit uses a system authorization sheet, not an OAuth URL.
    result.success('');
  }

  checkHealthAppAuthorization(result: Result<HealthAuthorization>): void {
    this.getAuthorization().then((response: healthStore.AuthorizationResponse) => {
      // The API returns only the data types currently granted to this app.
      // A non-empty intersection means the app can read at least one metric.
      const hasData = response.readDataTypes.length > 0;
      result.success(new HealthAuthorization(hasData ? 1 : 0, hasData));
    }).catch((error: Error) => {
      // The user still needs to accept the Huawei Health privacy agreement.
      // Return "needs authorization" so the user action can launch Health.
      console.error(`checkHealthAppAuthorization failed: ${error.name}: ${error.message}`);
      result.success(new HealthAuthorization(
        this.requiresHealthAppPrivacyAuthorization(error) ? 0 : -1,
        false,
      ));
    });
  }

  requestHealthClientAuthorization(result: Result<boolean>): void {
    this.requestAuthorizations().then((response: healthStore.AuthorizationResponse) => {
      console.error(`requestHealthClientAuthorization Huawei success`);
      result.success(response.readDataTypes.length > 0);
    }).catch((error: Error) => {
      const code = (error as BusinessError).code;
      console.error(`requestHealthClientAuthorization failed: ${code}, ${error.name}: ${error.message}`);
      if (!this.requiresHealthAppPrivacyAuthorization(error)) {
        result.success(false);
        return;
      }

      // Privacy consent is managed by the Huawei Health app. Opening it does
      // not grant this application's data types yet, so Flutter must re-check
      // authorization after the user returns.
      this.openHealthAppForPrivacyAuthorization().then(() => {
        result.success(false);
      }).catch(() => {
        result.success(false);
      });
    });
  }

  /**
   * Triggers the manual device-to-cloud synchronization exposed by Huawei
   * Health. The application must have obtained the "manual data sync"
   * permission in the Health Service Kit console before this call can work.
   */
  syncHealthDataToCloud(result: Result<boolean>): void {
    this.syncAllHealthData().then(() => {
      result.success(true);
    }).catch((error: Error) => {
      console.error(`syncHealthDataToCloud failed: ${error.name}: ${error.message}`);
      result.success(false);
    });
  }

  cancelAuthorizations(result: Result<boolean>): void {
    this.ensureInitialized()
      .then(() => healthStore.cancelAuthorizations())
      .then(() => result.success(true))
      .catch((error: BusinessError) => {
        console.error(`cancelAuthorizations failed: ${error.code}, ${error.message}`);
        result.success(false);
      });
  }

  readActivityGoal(result: Result<HealthActivityGoal | undefined>): void {
    this.ensureInitialized()
      .then(() => healthService.workout.readActivityReport())
      .then((report: healthService.workout.ActivityReport) => {
        result.success(new HealthActivityGoal(
          this.activeCaloriesToMoveGoal(report.activeCaloriesGoal),
          report.stepsGoal,
          report.activeHoursGoal,
          this.exerciseMinutesToSeconds(report.exerciseGoal),
        ));
      })
      .catch((error: Error) => {
        console.error(`readActivityGoal failed: ${error.name}: ${error.message}`);
        result.error(error);
      });
  }

  readWorkoutData(
    startTime: number,
    endTime: number,
    result: Result<Array<HealthWorkoutDataPoint>>,
  ): void {
    this.ensureInitialized()
      .then(() => healthStore.readData<healthStore.ExerciseSequence>({
        startTime: startTime * 1000,
        endTime: endTime * 1000,
        exerciseType: null,
      }))
      .then((records: Array<healthStore.ExerciseSequence>) => {
        result.success(records.map((record: healthStore.ExerciseSequence) => {
          return new HealthWorkoutDataPoint(
            Math.floor(record.startTime / 1000),
            Math.floor(record.endTime / 1000),
            record.exerciseType.id,
          );
        }));
      })
      .catch((error: Error) => {
        console.error(`readWorkoutData failed: ${error.name}: ${error.message}`);
        result.error(error);
      });
  }

  private async getAuthorization(): Promise<healthStore.AuthorizationResponse> {
    await this.ensureInitialized();
    return healthStore.getAuthorizations(this.authorizationRequest);
  }

  private exerciseMinutesToSeconds(value: number | undefined): number | undefined {
    return value === undefined ? undefined : Math.round(value * 60);
  }

  private activeCaloriesToMoveGoal(value: number | undefined): number | undefined {
    return value === undefined ? undefined : Math.floor(value / 1000);
  }

  private async requestAuthorizations(): Promise<healthStore.AuthorizationResponse> {
    await this.ensureInitialized();
    return healthStore.requestAuthorizations(this.context, this.authorizationRequest);
  }

  private async syncAllHealthData(): Promise<void> {
    await this.ensureInitialized();
    await healthStore.syncAll();
  }

  private async ensureInitialized(): Promise<void> {
    await healthStore.init(this.context);
  }

  private requiresHealthAppPrivacyAuthorization(error: Error): boolean {
    // Health Service Kit uses 1002703001 when the user has not accepted the
    // Huawei Health privacy agreement.
    return (error as BusinessError).code === 1002703001;
  }

  async openHealthAppForPrivacyAuthorization(): Promise<void> {
    if (this.canOpenHealthApp()) {
      await this.openInstalledHealthApp();
      return;
    }
    this.openHealthAppInAppGallery();
  }

  private canOpenHealthApp(): boolean {
    try {
      return bundleManager.canOpenLink(HealthKitHostApiImpl.HEALTH_APP_LINK);
    } catch (error) {
      console.error(`Health app availability check failed: ${error.name}: ${error.message}`);
      return false;
    }
  }

  private async openInstalledHealthApp(): Promise<void> {
    try {
      console.error(`Opening Huawei Health`);
      await this.context.openLink(HealthKitHostApiImpl.HEALTH_APP_LINK);
    } catch (error) {
      console.error(`Failed to open Huawei Health: ${error.name}: ${error.message}`);
      this.openHealthAppInAppGallery();
    }
  }

  private openHealthAppInAppGallery(): void {
    const want: Want = {
      parameters: {
        bundleName: HealthKitHostApiImpl.HEALTH_APP_BUNDLE_NAME,
      },
    };
    try {
      productViewManager.loadProduct(this.context, want);
    } catch (error) {
      console.error(`Failed to open AppGallery recommendation: ${error.name}: ${error.message}`);
    }
  }
}