1/*
2 * Copyright (C) 2023 Huawei Device Co., Ltd.
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15
16import BackupExtensionAbility, {BundleVersion} from '@ohos.application.BackupExtensionAbility';
17import fs from '@ohos.file.fs';
18// @ts-ignore
19import mediabackup from '@ohos.multimedia.mediabackup';
20
21const TAG = 'MediaBackupExtAbility';
22
23const galleryAppName = 'com.huawei.photos';
24const mediaAppName = 'com.android.providers.media.module';
25
26const UPGRADE_RESTORE : number = 0;
27const DUAL_FRAME_CLONE_RESTORE : number = 1;
28const CLONE_RESTORE : number = 2;
29const I_PHONE_CLONE_RESTORE : number = 3;
30const OTHERS_PHONE_CLONE_RESTORE : number = 4;
31
32const UPGRADE_NAME = '0.0.0.0';
33const I_PHONE_FRAME_CLONE_NAME = '99.99.99.997';
34const OTHERS_PHONE_FRAME_CLONE_NAME = '99.99.99.998';
35const DUAL_FRAME_CLONE_NAME = '99.99.99.999';
36const STAT_KEY_RESULT_INFO = 'resultInfo';
37const STAT_KEY_TYPE = 'type';
38const STAT_KEY_ERROR_CODE = 'errorCode';
39const STAT_KEY_ERROR_INFO = 'errorInfo';
40const STAT_KEY_INFOS = 'infos';
41const STAT_KEY_BACKUP_INFO = 'backupInfo';
42const STAT_KEY_SUCCESS_COUNT = 'successCount';
43const STAT_KEY_DUPLICATE_COUNT = 'duplicateCount';
44const STAT_KEY_FAILED_COUNT = 'failedCount';
45const STAT_KEY_DETAILS = 'details';
46const STAT_KEY_NUMBER = 'number';
47const STAT_KEY_PROGRESS_INFO = 'progressInfo';
48const STAT_KEY_NAME = 'name';
49const STAT_KEY_PROCESSED = 'processed';
50const STAT_KEY_TOTAL = 'total';
51const STAT_KEY_IS_PERCENTAGE = 'isPercentage';
52const STAT_VALUE_ERROR_INFO = 'ErrorInfo';
53const STAT_VALUE_COUNT_INFO = 'CountInfo';
54const STAT_TYPE_PHOTO = 'photo';
55const STAT_TYPE_VIDEO = 'video';
56const STAT_TYPE_AUDIO = 'audio';
57const STAT_TYPE_PHOTO_VIDEO = 'photo&video';
58const STAT_TYPE_UPDATE = 'update';
59const STAT_TYPE_OTHER = 'other';
60const STAT_TYPE_ONGOING = 'ongoing';
61const STAT_TYPES = [STAT_TYPE_PHOTO, STAT_TYPE_VIDEO, STAT_TYPE_AUDIO];
62const RESULT_INFO_NUM = 2;
63const JS_TYPE_STRING = 'string';
64const JS_TYPE_BOOLEAN = 'boolean';
65const DEFAULT_RESTORE_EX_INFO = {
66  'resultInfo':
67  [
68    {
69      'type': STAT_VALUE_ERROR_INFO,
70      'errorCode': '13500099',
71      'errorInfo': 'Get restoreEx info failed'
72    },
73    {
74      'type': STAT_VALUE_COUNT_INFO,
75      'infos':
76      [
77        {
78          'backupInfo': STAT_TYPE_PHOTO,
79          'successCount': 0,
80          'duplicateCount': 0,
81          'failedCount': 0,
82          'details': null
83        },
84        {
85          'backupInfo': STAT_TYPE_VIDEO,
86          'successCount': 0,
87          'duplicateCount': 0,
88          'failedCount': 0,
89          'details': null
90        },
91        {
92          'backupInfo': STAT_TYPE_AUDIO,
93          'successCount': 0,
94          'duplicateCount': 0,
95          'failedCount': 0,
96          'details': null
97        }
98      ]
99    }
100  ]
101};
102const DEFAULT_BACKUP_INFO = [
103  {
104    'backupInfo': STAT_TYPE_PHOTO,
105    'number': 0
106  },
107  {
108    'backupInfo': STAT_TYPE_VIDEO,
109    'number': 0
110  },
111  {
112    'backupInfo': STAT_TYPE_AUDIO,
113    'number': 0
114  }
115];
116const DEFAULT_PROGRESS_INFO = {
117  'progressInfo': [
118  {
119    'name': STAT_TYPE_PHOTO_VIDEO,
120    'processed': 0,
121    'total': 0,
122    'isPercentage': false
123  },
124  {
125    'name': STAT_TYPE_AUDIO,
126    'processed': 0,
127    'total': 0,
128    'isPercentage': false
129  },
130  {
131    'name': STAT_TYPE_UPDATE,
132    'processed': 0,
133    'total': 0,
134    'isPercentage': false
135  },
136  {
137    'name': STAT_TYPE_OTHER,
138    'processed': 0,
139    'total': 0,
140    'isPercentage': false
141  },
142  {
143    'name': STAT_TYPE_ONGOING,
144    'processed': 0,
145    'total': 0,
146    'isPercentage': false
147  }]
148};
149
150export default class MediaBackupExtAbility extends BackupExtensionAbility {
151  async onBackup() : Promise<void> {
152    console.log(TAG, 'onBackup ok.');
153    console.time(TAG + ' BACKUP');
154    await mediabackup.startBackup(CLONE_RESTORE, galleryAppName, mediaAppName);
155    console.timeEnd(TAG + ' BACKUP');
156  }
157
158  async onRestore(bundleVersion : BundleVersion) : Promise<void> {
159    console.log(TAG, `onRestore ok ${JSON.stringify(bundleVersion)}`);
160    console.time(TAG + ' RESTORE');
161    const backupDir = this.context.backupDir + 'restore';
162    let sceneCode: number = this.getSceneCode(bundleVersion);
163    await mediabackup.startRestore(this.context, sceneCode, galleryAppName, mediaAppName, backupDir);
164    console.timeEnd(TAG + ' RESTORE');
165  }
166
167  async onRestoreEx(bundleVersion: BundleVersion, bundleInfo: string): Promise<string> {
168    console.log(TAG, `onRestoreEx ok ${JSON.stringify(bundleVersion)}, ${JSON.stringify(bundleInfo)}`);
169    console.time(TAG + ' RESTORE EX');
170    const backupDir = this.context.backupDir + 'restore';
171    let sceneCode: number = this.getSceneCode(bundleVersion);
172    let restoreExResult: string = await mediabackup.startRestoreEx(this.context, sceneCode, galleryAppName, mediaAppName, backupDir,
173      bundleInfo);
174    let restoreExInfo: string = await this.getRestoreExInfo(sceneCode, restoreExResult);
175    console.log(TAG, `GET restoreExInfo: ${restoreExInfo}`);
176    console.timeEnd(TAG + ' RESTORE EX');
177    return restoreExInfo;
178  }
179
180  getBackupInfo(): string {
181    console.log(TAG, 'getBackupInfo ok');
182    let tmpBackupInfo: string = mediabackup.getBackupInfo(CLONE_RESTORE);
183    let backupInfo: string;
184    if (!this.isBackupInfoValid(tmpBackupInfo)) {
185      console.error(TAG, 'backupInfo is invalid, return default');
186      backupInfo = JSON.stringify(DEFAULT_BACKUP_INFO);
187    } else {
188      backupInfo = tmpBackupInfo;
189    }
190    console.log(TAG, `GET backupInfo: ${backupInfo}`);
191    return backupInfo;
192  }
193
194  onProcess(): string {
195    console.log(TAG, 'onProcess ok');
196    let progressInfo: string = mediabackup.getProgressInfo();
197    if (progressInfo.length === 0 || !this.isProgressInfoValid(progressInfo)) {
198      console.error(TAG, 'progressInfo is empty or invalid, return default');
199      progressInfo = JSON.stringify(DEFAULT_PROGRESS_INFO);
200    }
201    console.log(TAG, `GET progressInfo: ${progressInfo}`);
202    return progressInfo;
203  }
204
205  private async getRestoreExInfo(sceneCode: number, restoreExResult: string): Promise<string> {
206    if (!this.isRestoreExResultValid(restoreExResult)) {
207      console.error(TAG, 'restoreEx result is invalid, use default');
208      return JSON.stringify(DEFAULT_RESTORE_EX_INFO);
209    }
210    if (sceneCode !== UPGRADE_RESTORE) {
211      return restoreExResult;
212    }
213    try {
214      let jsonObject = JSON.parse(restoreExResult);
215      for (let info of jsonObject.resultInfo) {
216        if (info.type !== STAT_VALUE_COUNT_INFO) {
217          continue;
218        }
219        for (let subCountInfo of info.infos) {
220          let type = subCountInfo.backupInfo;
221          let detailsPath = subCountInfo.details;
222          subCountInfo.details = await this.getDetails(type, detailsPath);
223        }
224      }
225      return JSON.stringify(jsonObject);
226    } catch (err) {
227      console.error(TAG, `getRestoreExInfo error message: ${err.message}, code: ${err.code}`);
228      return JSON.stringify(DEFAULT_RESTORE_EX_INFO);
229    }
230  }
231
232  private async getDetails(type: string, detailsPath: string): Promise<null | number> {
233    if (detailsPath.length === 0) {
234      console.log(TAG, `${type} has no failed files`);
235      return null;
236    }
237    let file = await fs.open(detailsPath);
238    console.log(TAG, `${type} details fd: ${file.fd}`);
239    return file.fd;
240  }
241
242  private isRestoreExResultValid(restoreExResult: string): boolean {
243    try {
244      let jsonObject = JSON.parse(restoreExResult);
245      if (!this.hasKey(jsonObject, STAT_KEY_RESULT_INFO)) {
246        return false;
247      }
248      let resultInfo = jsonObject[STAT_KEY_RESULT_INFO];
249      if (resultInfo.length !== RESULT_INFO_NUM) {
250        console.error(TAG, `resultInfo num ${resultInfo.length} != ${RESULT_INFO_NUM}`);
251        return false;
252      }
253      let errorInfo = resultInfo[0];
254      let countInfo = resultInfo[1];
255      return this.isErrorInfoValid(errorInfo) && this.isCountInfoValid(countInfo);
256    } catch (err) {
257      console.error(TAG, `isRestoreExResultValid error message: ${err.message}, code: ${err.code}`);
258      return false;
259    }
260  }
261
262  private isErrorInfoValid(errorInfo: JSON): boolean {
263    if (!this.hasKey(errorInfo, STAT_KEY_TYPE) || !this.hasKey(errorInfo, STAT_KEY_ERROR_CODE) ||
264      !this.hasKey(errorInfo, STAT_KEY_ERROR_INFO)) {
265      return false;
266    }
267    if (errorInfo[STAT_KEY_TYPE] !== STAT_VALUE_ERROR_INFO) {
268      console.error(TAG, `errorInfo ${errorInfo[STAT_KEY_TYPE]} != ${STAT_VALUE_ERROR_INFO}`);
269      return false;
270    }
271    if (!this.checkType(typeof errorInfo[STAT_KEY_ERROR_CODE], JS_TYPE_STRING) ||
272      !this.checkType(typeof errorInfo[STAT_KEY_ERROR_INFO], JS_TYPE_STRING)) {
273      return false;
274    }
275    return true;
276  }
277
278  private isCountInfoValid(countInfo: JSON): boolean {
279    if (!this.hasKey(countInfo, STAT_KEY_TYPE) || !this.hasKey(countInfo, STAT_KEY_INFOS)) {
280      return false;
281    }
282    if (countInfo[STAT_KEY_TYPE] !== STAT_VALUE_COUNT_INFO) {
283      console.error(TAG, `countInfo ${countInfo[STAT_KEY_TYPE]} != ${STAT_VALUE_COUNT_INFO}`);
284      return false;
285    }
286    let subCountInfos = countInfo[STAT_KEY_INFOS];
287    if (subCountInfos.length !== STAT_TYPES.length) {
288      console.error(TAG, `countInfo ${subCountInfos.length} != ${STAT_TYPES.length}`);
289      return false;
290    }
291    let hasPhoto = false;
292    let hasVideo = false;
293    let hasAudio = false;
294    for (let subCountInfo of subCountInfos) {
295      if (!this.isSubCountInfoValid(subCountInfo)) {
296        return false;
297      }
298      hasPhoto = hasPhoto || subCountInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_PHOTO;
299      hasVideo = hasVideo || subCountInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_VIDEO;
300      hasAudio = hasAudio || subCountInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_AUDIO;
301    }
302    return hasPhoto && hasVideo && hasAudio;
303  }
304
305  private isSubCountInfoValid(subCountInfo: JSON): boolean {
306    if (!this.hasKey(subCountInfo, STAT_KEY_BACKUP_INFO) || !this.hasKey(subCountInfo, STAT_KEY_SUCCESS_COUNT) ||
307      !this.hasKey(subCountInfo, STAT_KEY_DUPLICATE_COUNT) || !this.hasKey(subCountInfo, STAT_KEY_FAILED_COUNT) ||
308      !this.hasKey(subCountInfo, STAT_KEY_DETAILS)) {
309      return false;
310    }
311    if (!STAT_TYPES.includes(subCountInfo[STAT_KEY_BACKUP_INFO])) {
312      console.error(TAG, `SubCountInfo ${subCountInfo[STAT_KEY_BACKUP_INFO]} not in ${JSON.stringify(STAT_TYPES)}`);
313      return false;
314    }
315    return !isNaN(subCountInfo[STAT_KEY_SUCCESS_COUNT]) && !isNaN(subCountInfo[STAT_KEY_DUPLICATE_COUNT]) &&
316      !isNaN(subCountInfo[STAT_KEY_FAILED_COUNT]);
317  }
318
319  private isBackupInfoValid(backupInfo: string): boolean {
320    try {
321      let jsonObject = JSON.parse(backupInfo);
322      if (jsonObject.length !== STAT_TYPES.length) {
323        console.error(TAG, `backupInfo num ${jsonObject.length} != ${STAT_TYPES.length}`);
324        return false;
325      }
326      let hasPhoto = false;
327      let hasVideo = false;
328      let hasAudio = false;
329      for (let subBackupInfo of jsonObject) {
330        if (!this.isSubBackupInfoValid(subBackupInfo)) {
331          return false;
332        }
333        hasPhoto = hasPhoto || subBackupInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_PHOTO;
334        hasVideo = hasVideo || subBackupInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_VIDEO;
335        hasAudio = hasAudio || subBackupInfo[STAT_KEY_BACKUP_INFO] === STAT_TYPE_AUDIO;
336      }
337      return hasPhoto && hasVideo && hasAudio;
338    } catch (err) {
339      console.error(TAG, `isBackupInfoValid error message: ${err.message}, code: ${err.code}`);
340      return false;
341    }
342  }
343
344  private isSubBackupInfoValid(subBackupInfo: JSON): boolean {
345    if (!this.hasKey(subBackupInfo, STAT_KEY_BACKUP_INFO) || !this.hasKey(subBackupInfo, STAT_KEY_NUMBER)) {
346      return false;
347    }
348    if (!STAT_TYPES.includes(subBackupInfo[STAT_KEY_BACKUP_INFO])) {
349      console.error(TAG, `SubBackupInfo ${subBackupInfo[STAT_KEY_BACKUP_INFO]} not in ${JSON.stringify(STAT_TYPES)}`);
350      return false;
351    }
352    return !isNaN(subBackupInfo[STAT_KEY_NUMBER]);
353  }
354
355  private hasKey(jsonObject: JSON, key: string): boolean {
356    if (!(key in jsonObject)) {
357      console.error(TAG, `hasKey ${key} not found`);
358      return false;
359    }
360    return true;
361  }
362
363  private checkType(varType: string, expectedType: string): boolean {
364    if (varType !== expectedType) {
365      console.error(TAG, `checkType ${varType} != ${expectedType}`);
366      return false;
367    }
368    return true;
369  }
370
371  private isProgressInfoValid(progressInfo: string): boolean {
372    try {
373      let jsonObject = JSON.parse(progressInfo);
374      if (!this.hasKey(jsonObject, STAT_KEY_PROGRESS_INFO)) {
375        return false;
376      }
377      let subProcessInfos = jsonObject[STAT_KEY_PROGRESS_INFO];
378      for (let subProcessInfo of subProcessInfos) {
379        if (!this.isSubProcessInfoValid(subProcessInfo)) {
380          return false;
381        }
382      }
383      return true;
384    } catch (err) {
385      console.error(TAG, `isProgressInfoValid error message: ${err.message}, code: ${err.code}`);
386      return false;
387    }
388  }
389
390  private isSubProcessInfoValid(subProcessInfo: JSON): boolean {
391    if (!this.hasKey(subProcessInfo, STAT_KEY_NAME) || !this.hasKey(subProcessInfo, STAT_KEY_PROCESSED) ||
392      !this.hasKey(subProcessInfo, STAT_KEY_TOTAL) || !this.hasKey(subProcessInfo, STAT_KEY_IS_PERCENTAGE)) {
393      return false;
394    }
395    return !isNaN(subProcessInfo[STAT_KEY_PROCESSED]) && !isNaN(subProcessInfo[STAT_KEY_TOTAL]) &&
396      this.checkType(typeof subProcessInfo[STAT_KEY_IS_PERCENTAGE], JS_TYPE_BOOLEAN);
397  }
398
399  private getSceneCode(bundleVersion: BundleVersion): number {
400    if (bundleVersion.name.startsWith(UPGRADE_NAME)) {
401      return UPGRADE_RESTORE;
402    }
403    if (bundleVersion.name === DUAL_FRAME_CLONE_NAME && bundleVersion.code === 0) {
404      return DUAL_FRAME_CLONE_RESTORE;
405    }
406    if (bundleVersion.name === OTHERS_PHONE_FRAME_CLONE_NAME && bundleVersion.code === 0) {
407      return OTHERS_PHONE_CLONE_RESTORE;
408    }
409    if (bundleVersion.name === I_PHONE_FRAME_CLONE_NAME && bundleVersion.code === 0) {
410      return I_PHONE_CLONE_RESTORE;
411    }
412    return CLONE_RESTORE;
413  }
414}
415