From ede3e8ea8d2cfaa5e7c0a909880ecb30c615c72d Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sun, 21 Dec 2025 20:43:38 +0800 Subject: [PATCH 01/16] persistent notification --- .../main/ets/entryability/EntryAbility.ets | 27 ++++- entry/src/main/ets/models/Category.ets | 68 +++++++++--- .../main/ets/models/ImageListDataSource.ets | 5 + entry/src/main/ets/models/PhotoItem.ets | 7 +- entry/src/main/ets/pages/Index.ets | 12 ++- entry/src/main/ets/pages/LoginPage.ets | 2 +- entry/src/main/ets/pages/UploadPage.ets | 88 ++++----------- entry/src/main/ets/utils/GlobalStorage.ets | 3 + .../main/ets/utils/NotificationManager.ets | 101 ++++++++++++++++++ entry/src/main/ets/utils/Path.ets | 2 +- entry/src/main/ets/utils/PhotoMonitor.ets | 72 +++++++++++++ entry/src/main/ets/utils/UploadScheduler.ets | 99 +++++++++++++++++ entry/src/main/ets/utils/Utils.ets | 10 +- entry/src/main/module.json5 | 6 ++ .../resources/base/media/layered_image.json | 9 +- 15 files changed, 406 insertions(+), 105 deletions(-) create mode 100644 entry/src/main/ets/utils/NotificationManager.ets create mode 100644 entry/src/main/ets/utils/PhotoMonitor.ets create mode 100644 entry/src/main/ets/utils/UploadScheduler.ets diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 8cfdbac..c057df3 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -4,13 +4,24 @@ import { Logger, NCommon } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; +import { Notification } from '../utils/NotificationManager'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; + const TAG = 'EntryAbility'; export default class EntryAbility extends UIAbility { - onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + id: number = 0; + + async onCreate(_want: Want, _launchParam: AbilityConstant.LaunchParam) { Logger.Info(TAG, 'Ability onCreate'); + try { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } catch (error) { + Logger.Error(TAG, `failed to set color mode!`); + } NCommon.Initialise(this.context); + GlobalStorage.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); + await Notification.startNotification(this.context); } onDestroy() { @@ -35,15 +46,21 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Main window created.'); } + onWindowStageDestroy() { Logger.Info(TAG, 'Main window is destroyed, release UI related resources'); } - onForeground() { + + async onForeground() { Logger.Info(TAG, 'Ability has brought to foreground'); } - onBackground() { + async onWillBackground() { Logger.Info(TAG, 'Ability has back to background'); } -} + + onMemoryLevel(level: AbilityConstant.MemoryLevel): void { + Logger.Info(TAG, `onMemoryLevel: level = ${AbilityConstant.MemoryLevel[level]}`); + } +}; diff --git a/entry/src/main/ets/models/Category.ets b/entry/src/main/ets/models/Category.ets index 1bae934..8e6210e 100644 --- a/entry/src/main/ets/models/Category.ets +++ b/entry/src/main/ets/models/Category.ets @@ -2,6 +2,7 @@ import { Logger, NCommon } from 'nextcloud'; import { ImageListDataSource } from './ImageListDataSource'; import { PhotoItem, UploadResult } from './PhotoItem'; import { promptAction } from '@kit.ArkUI'; +import { Notification } from '../utils/NotificationManager'; const TAG = 'models/Category'; @@ -11,7 +12,13 @@ export enum AddAssetResult { Throttled } -const IMAGE_LIST_MAX_ITEMS = 100; +export enum AddPosition { + Append, + Prepend +} + + +const IMAGE_LIST_MAX_ITEMS = 50; @Observed export class Category implements NCommon.CategoryConfig { @@ -22,7 +29,7 @@ export class Category implements NCommon.CategoryConfig { * Items that are currently visible on the UI (not all assets are listed for performance reason) */ visibleItems: ImageListDataSource = new ImageListDataSource(); - backedItems: PhotoItem[] = []; + invisibleBackedItems: Map = new Map(); constructor(other: NCommon.CategoryConfig) { this.name = other.name; @@ -30,26 +37,34 @@ export class Category implements NCommon.CategoryConfig { this.location = other.location; } - tryAddAsset(asset: PhotoItem): AddAssetResult { - for (let p of this.patterns) { - if (RegExp(p).test(asset.displayName)) { + tryAddAsset(asset: PhotoItem, position: AddPosition): AddAssetResult { + if (!this.matchesAsset(asset)) { + return AddAssetResult.Unmatched; + } + + switch (position) { + case AddPosition.Append: { if (this.visibleItems.totalCount() > IMAGE_LIST_MAX_ITEMS) { - this.backedItems.push(asset); + this.invisibleBackedItems.set(asset.uri, asset); return AddAssetResult.Throttled; } else { - this.visibleItems.addAsset(asset) + this.visibleItems.addAsset(asset); return AddAssetResult.Added; } } + case AddPosition.Prepend: { + this.visibleItems.prependAsset(asset); + return AddAssetResult.Added; + } } - return AddAssetResult.Unmatched; } showNext50(): void { Logger.Info(TAG, `loadNext50 enter`); let count = 0; - while (this.backedItems.length > 0 && count < 50) { - let asset = this.backedItems.shift()!; + while (this.invisibleBackedItems.size > 0 && count < 50) { + let asset = this.invisibleBackedItems.values().next().value as PhotoItem; + this.invisibleBackedItems.delete(asset.uri); this.visibleItems.addAsset(asset); count++; } @@ -63,28 +78,30 @@ export class Category implements NCommon.CategoryConfig { } else if (start >= this.visibleItems.totalCount()) { // Get from backedItems if it's completely within backedItems let backedStart = start - this.visibleItems.totalCount(); - for (let i = backedStart; i < Math.min(backedStart + length, this.backedItems.length); i++) { - result.push(this.backedItems[i]); + for (let i = backedStart; i < Math.min(backedStart + length, this.invisibleBackedItems.size); i++) { + result.push(this.invisibleBackedItems[i]); } } else { // Get from both items and backedItems let itemsPartLength = this.visibleItems.totalCount() - start; result = this.visibleItems.getDataRange(start, itemsPartLength); let backedPartLength = length - itemsPartLength; - for (let i = 0; i < Math.min(backedPartLength, this.backedItems.length); i++) { - result.push(this.backedItems[i]); + for (let i = 0; i < Math.min(backedPartLength, this.invisibleBackedItems.size); i++) { + result.push(this.invisibleBackedItems[i]); } } return result; } getTotalAvailableCount(): number { - return this.visibleItems.totalCount() + this.backedItems.length; + return this.visibleItems.totalCount() + this.invisibleBackedItems.size; } - async uploadAllAssets() { + async uploadAllAssets(notify: boolean = true) { const BATCH_SIZE = 8; - let total = Math.ceil(this.getTotalAvailableCount() / BATCH_SIZE) * BATCH_SIZE; + const totalCount = this.getTotalAvailableCount(); + let total = Math.ceil(totalCount / BATCH_SIZE) * BATCH_SIZE; + let totalUploaded = 0; for (let i = 0; i < total; i += BATCH_SIZE) { let batch = this.getAssets(i, BATCH_SIZE); @@ -93,11 +110,19 @@ export class Category implements NCommon.CategoryConfig { let futures = batch.map(asset => { return asset.upload(NCommon.Preference.baseUploadDirectory + this.location).then((result) => { canStop = canStop && result === UploadResult.EXISTED; + totalUploaded++; + }).then(() => { + if (notify) { + Notification.publishUploadProgress(totalUploaded, totalCount) + } }); }); await Promise.all(futures); if (canStop) { + if (notify) { + Notification.publishIdleNotification(); + } promptAction.showToast({ message: `Upload is probably completed`, duration: 2000, @@ -106,4 +131,13 @@ export class Category implements NCommon.CategoryConfig { } } } + + private matchesAsset(asset: PhotoItem): boolean { + for (let p of this.patterns) { + if (RegExp(p).test(asset.displayName)) { + return true; + } + } + return false; + } } diff --git a/entry/src/main/ets/models/ImageListDataSource.ets b/entry/src/main/ets/models/ImageListDataSource.ets index e6b9a23..ff42566 100644 --- a/entry/src/main/ets/models/ImageListDataSource.ets +++ b/entry/src/main/ets/models/ImageListDataSource.ets @@ -7,6 +7,11 @@ export class ImageListDataSource extends BasicDataSource { super(); } + public prependAsset(asset: PhotoItem) { + this.entries.unshift(asset); + this.notifyDataAdd(0); + } + public addAsset(asset: PhotoItem) { this.entries.push(asset); this.notifyDataAdd(this.entries.length - 1); diff --git a/entry/src/main/ets/models/PhotoItem.ets b/entry/src/main/ets/models/PhotoItem.ets index 8099bc5..7adef15 100644 --- a/entry/src/main/ets/models/PhotoItem.ets +++ b/entry/src/main/ets/models/PhotoItem.ets @@ -1,10 +1,11 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; import { CreatePath, Logger, UploadFile } from 'nextcloud'; import { promptAction } from '@kit.ArkUI'; -import { TimeToPath } from '../utils/Utils'; +import { FormatTime } from '../utils/Utils'; import { image } from '@kit.ImageKit'; const TAG = 'PhotoItem'; +const DEFAULT_FORMAT = 'yyyy/MM/' export enum UploadStatus { None, @@ -67,7 +68,7 @@ export class PhotoItem { async upload(targetPath: string): Promise { this.uploadStatus = UploadStatus.Uploading; - let path = targetPath + TimeToPath(this.dateTaken); + let path = targetPath + FormatTime(this.dateTaken, DEFAULT_FORMAT); let mkdirResult = await CreatePath(path); if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { // 405 is the status code for "already exists" @@ -81,8 +82,6 @@ export class PhotoItem { let ret: UploadResult = UploadResult.FAILED; let message = ""; - let fd = await this.innerAsset.getReadOnlyFd(); - let uploadResult = await UploadFile(this.displayName, this.dateTakenMs, path, fd); let uploadResult = await UploadFile(this.displayName, this.dateTakenMs, path, this.uri); if (uploadResult !== 201 && uploadResult !== 204) { message = `Failed: ${this.displayName}: ${uploadResult}`; diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 8b15365..61a250c 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -6,6 +6,9 @@ import { LoginPage } from './LoginPage'; import { SettingsPage } from './SettingsPage'; import { FolderPage } from './FolderPage'; import { Constants, FolderViewMode } from '../utils/Common'; +import { PermissionManager } from '../utils/PermissionManager'; +import { UploadScheduler } from '../utils/UploadScheduler'; +import { PhotoMonitor } from '../utils/PhotoMonitor'; const TAG = "Page-Index"; @@ -50,8 +53,15 @@ struct Index { } } - aboutToAppear(): void { + async aboutToAppear() { Logger.Info(TAG, `aboutToAppear`); + let context = this.getUIContext().getHostContext()!; + let result1 = await PermissionManager.RequestPermission(context, 'ohos.permission.READ_IMAGEVIDEO'); + Logger.Info(TAG, `Request permission result: ${result1}`); + let result2 = await PermissionManager.RequestPermission(context, 'ohos.permission.MEDIA_LOCATION'); + Logger.Info(TAG, `Request permission result: ${result2}`); + UploadScheduler.initialize(); + PhotoMonitor.beginMonitorPhotoChange(); } async doLogin() { diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index ed22796..9f25ba6 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -1,5 +1,5 @@ import { LoginProcess, NCommon } from 'nextcloud'; -import { LengthMetrics, promptAction } from '@kit.ArkUI'; +import { LengthMetrics } from '@kit.ArkUI'; import { Logger } from 'nextcloud/src/main/ets/Logger'; import { webview } from '@kit.ArkWeb'; diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index c87a65a..6f6b80a 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,41 +1,35 @@ import { Logger, NCommon } from 'nextcloud'; -import { PermissionManager } from '../utils/PermissionManager'; -import { AddAssetResult, Category } from '../models/Category'; +import { Category } from '../models/Category'; import { LengthMetrics } from '@kit.ArkUI'; -import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { dataSharePredicates } from '@kit.ArkData'; import { PhotoItem, UploadStatus } from '../models/PhotoItem'; +import { UploadScheduler } from '../utils/UploadScheduler'; const TAG = "Page-UploadPage" -const FETCH_OPTIONS: PHelper.FetchOptions = { - fetchColumns: [ - PHelper.PhotoKeys.URI, - PHelper.PhotoKeys.PHOTO_TYPE, - PHelper.PhotoKeys.PHOTO_SUBTYPE, - PHelper.PhotoKeys.DISPLAY_NAME, - PHelper.PhotoKeys.DATE_TAKEN, - PHelper.PhotoKeys.DATE_ADDED_MS, - PHelper.PhotoKeys.SIZE, - PHelper.PhotoKeys.WIDTH, - PHelper.PhotoKeys.HEIGHT, - PHelper.PhotoKeys.TITLE, - PHelper.PhotoKeys.COVER_POSITION, - PHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, - PHelper.PhotoKeys.DETAIL_TIME, - PHelper.PhotoKeys.DATE_TAKEN_MS, - ], - predicates: new dataSharePredicates.DataSharePredicates().orderByDesc(PHelper.PhotoKeys.DATE_TAKEN), -}; - - @Component struct PhotoView { @ObjectLink asset: PhotoItem; - @Prop category: Category; + @ObjectLink category: Category; @State private thumbnail: PixelMap | null = null; + getUploadColor(status: UploadStatus): Color { + switch (status) { + case UploadStatus.Uploaded: + return Color.Green; + case UploadStatus.Uploading: + return Color.Yellow; + case UploadStatus.Failed: + return Color.Red; + case UploadStatus.None: + return Color.Gray; + case UploadStatus.Existed: + return Color.Blue; + default: + return Color.Gray; + } + } + build() { Stack() { Image(this.thumbnail) @@ -52,12 +46,7 @@ struct PhotoView { .height(100) .width(100) .borderWidth(3) - .borderColor( - (this.asset.uploadStatus == UploadStatus.Uploaded) ? Color.Green : - (this.asset.uploadStatus == UploadStatus.Uploading) ? Color.Yellow : - (this.asset.uploadStatus == UploadStatus.Failed) ? Color.Red : - (this.asset.uploadStatus == UploadStatus.None) ? Color.Gray : - (this.asset.uploadStatus == UploadStatus.Existed) ? Color.Blue : Color.Gray) + .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { await this.asset.upload(NCommon.Preference.baseUploadDirectory + this.category.location); }) @@ -117,17 +106,7 @@ struct PhotosGridView { @Preview @Component export struct UploadPage { - @State private categories: Category[] = []; - - async aboutToAppear() { - let context = this.getUIContext().getHostContext()!; - let result1 = await PermissionManager.RequestPermission(context, 'ohos.permission.READ_IMAGEVIDEO'); - Logger.Info(TAG, `Request permission result: ${result1}`); - let result2 = await PermissionManager.RequestPermission(context, 'ohos.permission.MEDIA_LOCATION'); - Logger.Info(TAG, `Request permission result: ${result2}`); - - this.initializeCategoriesAndAssets(context); - } + @State private categories: Category[] = UploadScheduler.categories; build() { Tabs() { @@ -141,7 +120,7 @@ export struct UploadPage { Text(category.location) } - .height('32') + .height(32) ForEach(category.patterns, (pattern: string) => { Text(pattern) @@ -160,25 +139,4 @@ export struct UploadPage { }) } } - - private async initializeCategoriesAndAssets(context: Context) { - while (this.categories.length > 0) { - this.categories.pop(); - } - for (let c of NCommon.Preference.categories) { - this.categories.push(new Category(c)); - } - - let helper = PHelper.getPhotoAccessHelper(context); - let result = await helper.getAssets(FETCH_OPTIONS); - for (let asset of await result.getAllObjects()) { - let photoItem = new PhotoItem(asset); - for (let category of this.categories) { - if (category.tryAddAsset(photoItem) != AddAssetResult.Unmatched) { - break; - } - } - } - result.close(); - } } diff --git a/entry/src/main/ets/utils/GlobalStorage.ets b/entry/src/main/ets/utils/GlobalStorage.ets index 7e1567b..6448c70 100644 --- a/entry/src/main/ets/utils/GlobalStorage.ets +++ b/entry/src/main/ets/utils/GlobalStorage.ets @@ -1,5 +1,8 @@ +import { photoAccessHelper } from '@kit.MediaLibraryKit'; + export const INSTANT_UPLOAD_PATH = '/InstantUpload/'; export class GlobalStorage { public static SandboxBasePath: string = ""; + public static pHelper: photoAccessHelper.PhotoAccessHelper; } diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets new file mode 100644 index 0000000..f3b9676 --- /dev/null +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -0,0 +1,101 @@ +import { wantAgent } from '@kit.AbilityKit'; +import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'; +import { Logger } from 'nextcloud'; +import { BUNDLE_NAME } from 'BuildProfile'; +import { BusinessError } from '@ohos.base'; +import { notificationManager } from '@kit.NotificationKit'; +import { UploadScheduler } from './UploadScheduler'; + +const TAG = "Util-NotificationManager"; + +export namespace Notification { + const WANT_AGENT_INFO: wantAgent.WantAgentInfo = { + wants: [{ bundleName: BUNDLE_NAME, abilityName: "EntryAbility" }], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }; + const BACKGROUND_JOB_TYPE: Array = ["dataTransfer"]; + let notificationId: Optional = undefined; + let refreshNotificationTimer: number = 0; + + + export async function startNotification(context: Context) { + try { + let wantAgentObj = await wantAgent.getWantAgent(WANT_AGENT_INFO); + let res = await backgroundTaskManager.startBackgroundRunning(context, BACKGROUND_JOB_TYPE, wantAgentObj); + notificationId = res.notificationId; + await publishIdleNotification(); + Logger.Info(TAG, `Operation startBackgroundRunning succeeded: ${notificationId}`); + } catch (error) { + let e = error as BusinessError; + Logger.Error(TAG, `Operation startBackgroundRunning failed. code is ${e.code} message is ${e.message}`); + } + } + + export async function publishUploadProgress(currentNumber: number, totalNumber: number) { + if (notificationId === undefined) { + Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); + return; + } + let process = Math.floor((currentNumber / totalNumber) * 100); + await publishUploadProgressInner('Nextcloud Upload', `Uploading ${currentNumber} of ${totalNumber}`, process); + } + + export async function publishIdleNotification() { + if (notificationId === undefined) { + Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); + return; + } + + let randomProgress = Math.floor(Math.random() * 100); + await UploadScheduler.doFullUpload(false); + await publishUploadProgressInner('Nextcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, randomProgress); + + clearInterval(refreshNotificationTimer); + refreshNotificationTimer = setInterval(async () => { + await publishIdleNotification(); + }, 3 * 30 * 1000); + } + + export async function publishPendingNotification(due: Date, percent: number) { + if (notificationId === undefined) { + Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); + return; + } + await publishUploadProgressInner('Nextcloud Pending Upload', `Scheduled at ${due.toLocaleTimeString()}`, percent); + } + + async function publishUploadProgressInner(title: string, subtitle: string, progress: number) { + clearInterval(refreshNotificationTimer); + await doPublishUploadProgress(title, subtitle, progress); + } + + async function doPublishUploadProgress(title: string, subtitle: string, progress: number) { + Logger.Info(TAG, `publishUploadProgress: title='${title}', subtitle='${subtitle}', progress=${progress} to ${notificationId}`); + let downloadTemplate: notificationManager.NotificationTemplate = { + name: 'downloadTemplate', + data: { + title: title, + fileName: subtitle, + progressValue: progress, + } + }; + + let request: notificationManager.NotificationRequest = { + content: { + notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_SYSTEM_LIVE_VIEW, + systemLiveView: { typeCode: 8, title: '', text: '', } + }, + id: notificationId, + notificationSlotType: notificationManager.SlotType.LIVE_VIEW, + template: downloadTemplate, + }; + + try { + await notificationManager.publish(request); + } catch (err) { + Logger.Error(TAG, `publish fail: ${JSON.stringify(err)}`); + } + } +} diff --git a/entry/src/main/ets/utils/Path.ets b/entry/src/main/ets/utils/Path.ets index 28768ca..7842ae4 100644 --- a/entry/src/main/ets/utils/Path.ets +++ b/entry/src/main/ets/utils/Path.ets @@ -1,4 +1,4 @@ -import { Logger } from "nextcloud"; +import { Logger } from 'nextcloud'; const TAG = "Util-Path"; diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets new file mode 100644 index 0000000..9ae2784 --- /dev/null +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -0,0 +1,72 @@ +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { Logger } from 'nextcloud'; +import { GlobalStorage } from './GlobalStorage'; +import { Notification } from './NotificationManager'; +import { UploadScheduler } from './UploadScheduler'; + +const TAG = 'Utils-PhotoMonitor'; + +export namespace PhotoMonitor { + let doUploadTimer: number | undefined = undefined; + const UPLOAD_DELAY_MS = 30 * 1000; // wait for this time before triggering full upload + + let fullUploadTimer: number | undefined = undefined; + const FULL_UPLOAD_INTERVAL_MS = 60 * 60 * 1000; // perform full upload at least every hour + + function scheduleFullUpload() { + clearInterval(fullUploadTimer); + + fullUploadTimer = setInterval(async () => { + Logger.Info(TAG, `>>> Scheduled full upload beginning`); + await UploadScheduler.doFullUpload(); + let nextTime = new Date(Date.now() + FULL_UPLOAD_INTERVAL_MS); + Logger.Info(TAG, `<<< Scheduled full upload completed, next time: ${nextTime}`); + }, FULL_UPLOAD_INTERVAL_MS); + + let nextUploadTime = new Date(Date.now() + FULL_UPLOAD_INTERVAL_MS); + Logger.Info(TAG, `+++ Scheduled full upload at ${nextUploadTime}`); + } + + async function doOnPhotoChanged(changeData: photoAccessHelper.ChangeData) { + if (changeData.type !== photoAccessHelper.NotifyType.NOTIFY_ADD) { + Logger.Debug(TAG, `change type isn't ADD, return directly`); + return; + } + + for (let uri of changeData.uris) { + await UploadScheduler.prependNewAsset(uri); + } + + clearTimeout(doUploadTimer); // cancel repeating upload timer + clearInterval(fullUploadTimer); // postpone full upload timer after photo change + + doUploadTimer = setTimeout(async () => { + Logger.Info(TAG, `>>> Scheduled photo upload beginning`); + await UploadScheduler.doFullUpload(); + Logger.Info(TAG, `<<< Scheduled photo upload completed`); + scheduleFullUpload(); + }, UPLOAD_DELAY_MS); + + let nextUploadTime = new Date(Date.now() + UPLOAD_DELAY_MS); + Notification.publishPendingNotification(nextUploadTime, 66); + Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); + } + + export function beginMonitorPhotoChange() { + try { + GlobalStorage.pHelper.registerChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, true, doOnPhotoChanged); + scheduleFullUpload(); + } catch (error) { + Logger.Error(TAG, `Failed to register photo change monitor: ${error}`); + } + } + + export function stopMonitorPhotoChange() { + try { + GlobalStorage.pHelper.unRegisterChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, doOnPhotoChanged); + } catch (error) { + Logger.Error(TAG, `Failed to unregister photo change monitor: ${error}`); + } + + } +} diff --git a/entry/src/main/ets/utils/UploadScheduler.ets b/entry/src/main/ets/utils/UploadScheduler.ets new file mode 100644 index 0000000..8d5fdbe --- /dev/null +++ b/entry/src/main/ets/utils/UploadScheduler.ets @@ -0,0 +1,99 @@ +import { Logger, NCommon } from 'nextcloud'; +import { AddAssetResult, AddPosition, Category } from '../models/Category'; +import { PhotoItem } from '../models/PhotoItem'; +import { GlobalStorage } from './GlobalStorage'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { dataSharePredicates } from '@kit.ArkData'; +import { Notification } from './NotificationManager'; + +const TAG = 'Utils-UploadScheduler'; + +export namespace UploadScheduler { + export let categories: Category[] = []; + + const FETCH_COLUMNS = [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.PHOTO_TYPE, + photoAccessHelper.PhotoKeys.PHOTO_SUBTYPE, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DATE_ADDED_MS, + photoAccessHelper.PhotoKeys.SIZE, + photoAccessHelper.PhotoKeys.WIDTH, + photoAccessHelper.PhotoKeys.HEIGHT, + photoAccessHelper.PhotoKeys.TITLE, + photoAccessHelper.PhotoKeys.COVER_POSITION, + photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, + photoAccessHelper.PhotoKeys.DETAIL_TIME, + photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, + ]; + + + function doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { + for (let category of categories) { + if (category.tryAddAsset(photoItem, addPosition) != AddAssetResult.Unmatched) { + break; + } + } + } + + + export async function initialize() { + while (categories.length > 0) { + categories.pop(); + } + + for (let c of NCommon.Preference.categories) { + categories.push(new Category(c)); + } + + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates().orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN), + }; + + try { + let result = await GlobalStorage.pHelper.getAssets(FETCH_OPTIONS); + for (let asset of await result.getAllObjects()) { + doAddPhotoItem(new PhotoItem(asset), AddPosition.Append); + } + result.close(); + } catch (error) { + Logger.Error(TAG, `failed to get photo assets: ${error}`); + } + } + + + export async function prependNewAsset(assetUri: string) { + Logger.Info(TAG, `addNewAsset enter, uri: ${assetUri}`); + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates().equalTo(photoAccessHelper.PhotoKeys.URI, assetUri), + }; + + try { + let assetResult = await GlobalStorage.pHelper.getAssets(FETCH_OPTIONS); + if (assetResult.getCount() !== 1) { + Logger.Error(TAG, `found more than one asset with uri: ${assetUri}`); + return; + } + let photoItem = new PhotoItem(await assetResult.getFirstObject()); + Logger.Info(TAG, `prepending new asset: ${photoItem.displayName}, uri: ${photoItem.uri}`); + doAddPhotoItem(photoItem, AddPosition.Prepend); + assetResult.close(); + } catch (error) { + Logger.Error(TAG, `failed to add new asset: ${error}`); + } + } + + export async function doFullUpload(notify: boolean = true) { + for (let category of UploadScheduler.categories) { + await category.uploadAllAssets(notify); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); // wait for 1 second to ensure all uploads are started + if (notify) { + await Notification.publishIdleNotification(); + } + } +} diff --git a/entry/src/main/ets/utils/Utils.ets b/entry/src/main/ets/utils/Utils.ets index 904c581..8381c39 100644 --- a/entry/src/main/ets/utils/Utils.ets +++ b/entry/src/main/ets/utils/Utils.ets @@ -1,5 +1,3 @@ -const DEFAULT_PATH_FORMAT = 'yyyy/MM/' - export function FormatBytes(bytes: string) { let bytesNum = parseInt(bytes); let units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -12,13 +10,13 @@ export function FormatBytes(bytes: string) { return bytesNum.toFixed(2) + ' ' + units[i]; } -export function TimeToPath(date: Date, pathFormat: string = DEFAULT_PATH_FORMAT) { +export function FormatTime(date: Date, format: string) { // make sure the path format ends with a slash - if (!pathFormat.endsWith('/')) { - pathFormat += '/'; + if (!format.endsWith('/')) { + format += '/'; } - let result = pathFormat; + let result = format; result = result.replace('yyyy', date.getFullYear().toString()); result = result.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0')); result = result.replace('dd', date.getDate().toString().padStart(2, '0')); diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index cd39c30..72cdce4 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -25,6 +25,9 @@ "EntryAbility" ] } + }, + { + "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" } ], "deviceTypes": [ @@ -55,6 +58,9 @@ "action.system.home" ] } + ], + "backgroundModes": [ + "dataTransfer" ] } ], diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json index fb49920..d2e870e 100644 --- a/entry/src/main/resources/base/media/layered_image.json +++ b/entry/src/main/resources/base/media/layered_image.json @@ -1,7 +1,6 @@ { - "layered-image": - { - "background" : "$media:background", - "foreground" : "$media:foreground" + "layered-image": { + "background": "$media:background", + "foreground": "$media:foreground" } -} \ No newline at end of file +} From bc9df9d4e9e545679457421d7baa7359107cceb6 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Wed, 24 Dec 2025 23:23:14 +0800 Subject: [PATCH 02/16] initial clouddisk sync work see also #1 --- entry/build-profile.json5 | 16 + entry/oh-package-lock.json5 | 11 +- entry/oh-package.json5 | 1 + entry/src/main/cpp/CMakeLists.txt | 23 + entry/src/main/cpp/include/Logging.hpp | 13 + entry/src/main/cpp/include/NapiUtils.hpp | 91 + entry/src/main/cpp/include/magic_enum.hpp | 1508 +++++++++++++++++ entry/src/main/cpp/napi_init.cpp | 163 ++ .../main/cpp/types/libcloudsync/Index.d.ts | 7 + .../cpp/types/libcloudsync/oh-package.json5 | 6 + .../main/ets/entryability/EntryAbility.ets | 17 +- entry/src/main/ets/pages/PcMainPage.ets | 67 + entry/src/main/ets/utils/Common.ets | 2 + .../resources/base/profile/main_pages.json | 3 +- nextcloud/oh-package-lock.json5 | 2 +- oh-package-lock.json5 | 4 +- 16 files changed, 1924 insertions(+), 10 deletions(-) create mode 100644 entry/src/main/cpp/CMakeLists.txt create mode 100644 entry/src/main/cpp/include/Logging.hpp create mode 100644 entry/src/main/cpp/include/NapiUtils.hpp create mode 100644 entry/src/main/cpp/include/magic_enum.hpp create mode 100644 entry/src/main/cpp/napi_init.cpp create mode 100644 entry/src/main/cpp/types/libcloudsync/Index.d.ts create mode 100644 entry/src/main/cpp/types/libcloudsync/oh-package.json5 create mode 100644 entry/src/main/ets/pages/PcMainPage.ets diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 index 20eceed..5100df5 100644 --- a/entry/build-profile.json5 +++ b/entry/build-profile.json5 @@ -1,6 +1,16 @@ { "apiType": "stageMode", "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + }, + "externalNativeOptions": { + "path": "src/main/cpp/CMakeLists.txt", + "arguments": "", + "cppFlags": "" + } }, "buildOptionSet": [ { @@ -14,6 +24,12 @@ ] } } + }, + "nativeLib": { + "debugSymbol": { + "strip": true, + "exclude": [] + } } }, ], diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 index 1fd3390..934e8d2 100644 --- a/entry/oh-package-lock.json5 +++ b/entry/oh-package-lock.json5 @@ -7,20 +7,27 @@ "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { "@ohos/axios@^2.2.4": "@ohos/axios@2.2.4", + "libcloudsync.so@src/main/cpp/types/libcloudsync": "libcloudsync.so@src/main/cpp/types/libcloudsync", "nextcloud@../nextcloud": "nextcloud@../nextcloud" }, "packages": { "@ohos/axios@2.2.4": { - "name": "", + "name": "@ohos/axios", "version": "2.2.4", "integrity": "sha512-5oH753kGFwfJP1kvCnI92RRaSqRbN0b24vmNNrUV+T8+5ako98rtVQOhA7ezMPox5Z3PFWndLQTF/P/hEK/yeQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/axios/-/axios-2.2.4.har", "registryType": "ohpm" }, + "libcloudsync.so@src/main/cpp/types/libcloudsync": { + "name": "libcloudsync.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libcloudsync", + "registryType": "local" + }, "nextcloud@../nextcloud": { "name": "nextcloud", "version": "1.0.0", - "resolved": "", + "resolved": "../nextcloud", "registryType": "local", "dependencies": { "@ohos/axios": "^2.2.4" diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 index 3b278fb..d59c8f3 100644 --- a/entry/oh-package.json5 +++ b/entry/oh-package.json5 @@ -7,5 +7,6 @@ "license": "", "dependencies": { "nextcloud": "file:../nextcloud", + "libcloudsync.so": "file:./src/main/cpp/types/libcloudsync" }, } diff --git a/entry/src/main/cpp/CMakeLists.txt b/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..1a3aa1c --- /dev/null +++ b/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,23 @@ +# the minimum version of CMake. +cmake_minimum_required(VERSION 3.5.0) +project(Nextcloud) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) +set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) + +if(DEFINED PACKAGE_FIND_FILE) + include(${PACKAGE_FIND_FILE}) +endif() + +include_directories(${NATIVERENDER_ROOT_PATH} + ${NATIVERENDER_ROOT_PATH}/include) + +add_library(cloudsync SHARED napi_init.cpp) +target_link_libraries( + cloudsync + PUBLIC + libace_napi.z.so + libohclouddiskmanager.so + libhilog_ndk.z.so +) diff --git a/entry/src/main/cpp/include/Logging.hpp b/entry/src/main/cpp/include/Logging.hpp new file mode 100644 index 0000000..9654e89 --- /dev/null +++ b/entry/src/main/cpp/include/Logging.hpp @@ -0,0 +1,13 @@ +#pragma once +#define LOG_DOMAIN 0 + +#ifndef LOG_TAG +#error "LOG_TAG must be defined" +#endif + +#include + +#define LogError(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_ERROR, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) +#define LogWarn(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_WARN, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) +#define LogInfo(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_INFO, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) +#define LogDebug(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_DEBUG, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) diff --git a/entry/src/main/cpp/include/NapiUtils.hpp b/entry/src/main/cpp/include/NapiUtils.hpp new file mode 100644 index 0000000..3643982 --- /dev/null +++ b/entry/src/main/cpp/include/NapiUtils.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include + +template +struct NapiValueConverter +{ + static constexpr napi_valuetype type = napi_undefined; + void Get(napi_env, napi_value){}; +}; + +#define DeclareNapiValueConverter(_type, _type_t, _convert_func) \ + template<> \ + struct NapiValueConverter<_type> \ + { \ + using type_t = _type_t; \ + using optional_t = std::optional; \ + static constexpr napi_valuetype type = _type; \ + static optional_t Get(napi_env env, napi_value value) \ + { \ + type_t ret; \ + napi_valuetype arg_type = napi_undefined; \ + napi_typeof(env, value, &arg_type); \ + if (arg_type != _type) \ + return std::nullopt; \ + _convert_func(env, value, &ret); \ + return ret; \ + } \ + } + +DeclareNapiValueConverter(napi_number, long, napi_get_value_int64); +DeclareNapiValueConverter(napi_boolean, bool, napi_get_value_bool); +DeclareNapiValueConverter(napi_string, std::string, + [](napi_env env, napi_value value, std::string *ret) + { + size_t len = 0; + napi_get_value_string_utf8(env, value, nullptr, 0, &len); + ret->resize(len, '\0'); + napi_get_value_string_utf8(env, value, ret->data(), len + 1, &len); + }); +DeclareNapiValueConverter(napi_function, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); +DeclareNapiValueConverter(napi_object, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); + +template +struct NapiArgs +{ + static constexpr auto NTypes = sizeof...(types); + using Result = std::tuple::type_t...>; + using OptionalResult = std::tuple::type_t>...>; + using NapiValueArray = std::array; + + template> + struct DoConvert; // doesn't need to be defined + + template + struct DoConvert> + { + static OptionalResult impl(napi_env env, NapiValueArray args) + { + return std::make_tuple(NapiValueConverter::Get(env, args[Is])...); + } + }; + + static std::optional Get(napi_env env, napi_callback_info info) + { + NapiValueArray args; + size_t argc = NTypes; + napi_get_cb_info(env, info, &argc, args.data(), nullptr, nullptr); + + if (argc != NTypes) + { + napi_throw_type_error(env, nullptr, "Wrong number of arguments"); + return {}; + } + + const auto optional_result = DoConvert<>::impl(env, args); + const bool is_all_valid = std::apply([](const auto &...args) { return (... && args.has_value()); }, optional_result); + if (!is_all_valid) + { + napi_throw_type_error(env, nullptr, "Wrong arguments"); + return {}; + } + + const auto result = std::apply([](const auto &...args) { return std::make_tuple(args.value()...); }, optional_result); + return std::make_optional(result); + } +}; diff --git a/entry/src/main/cpp/include/magic_enum.hpp b/entry/src/main/cpp/include/magic_enum.hpp new file mode 100644 index 0000000..158207e --- /dev/null +++ b/entry/src/main/cpp/include/magic_enum.hpp @@ -0,0 +1,1508 @@ +// __ __ _ ______ _____ +// | \/ | (_) | ____| / ____|_ _ +// | \ / | __ _ __ _ _ ___ | |__ _ __ _ _ _ __ ___ | | _| |_ _| |_ +// | |\/| |/ _` |/ _` | |/ __| | __| | '_ \| | | | '_ ` _ \ | | |_ _|_ _| +// | | | | (_| | (_| | | (__ | |____| | | | |_| | | | | | | | |____|_| |_| +// |_| |_|\__,_|\__, |_|\___| |______|_| |_|\__,_|_| |_| |_| \_____| +// __/ | https://github.com/Neargye/magic_enum +// |___/ version 0.9.7 +// +// Licensed under the MIT License . +// SPDX-License-Identifier: MIT +// Copyright (c) 2019 - 2024 Daniil Goncharov . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef NEARGYE_MAGIC_ENUM_HPP +#define NEARGYE_MAGIC_ENUM_HPP + +#define MAGIC_ENUM_VERSION_MAJOR 0 +#define MAGIC_ENUM_VERSION_MINOR 9 +#define MAGIC_ENUM_VERSION_PATCH 7 + +#ifndef MAGIC_ENUM_USE_STD_MODULE +#include +#include +#include +#include +#include +#include +#include +#endif + +#if defined(MAGIC_ENUM_CONFIG_FILE) +# include MAGIC_ENUM_CONFIG_FILE +#endif + +#ifndef MAGIC_ENUM_USE_STD_MODULE +#if !defined(MAGIC_ENUM_USING_ALIAS_OPTIONAL) +# include +#endif +#if !defined(MAGIC_ENUM_USING_ALIAS_STRING) +# include +#endif +#if !defined(MAGIC_ENUM_USING_ALIAS_STRING_VIEW) +# include +#endif +#endif + +#if defined(MAGIC_ENUM_NO_ASSERT) +# define MAGIC_ENUM_ASSERT(...) static_cast(0) +#elif !defined(MAGIC_ENUM_ASSERT) +# include +# define MAGIC_ENUM_ASSERT(...) assert((__VA_ARGS__)) +#endif + +#if defined(__clang__) +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wunknown-warning-option" +# pragma clang diagnostic ignored "-Wenum-constexpr-conversion" +# pragma clang diagnostic ignored "-Wuseless-cast" // suppresses 'static_cast('\0')' for char_type = char (common on Linux). +#elif defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wmaybe-uninitialized" // May be used uninitialized 'return {};'. +# pragma GCC diagnostic ignored "-Wuseless-cast" // suppresses 'static_cast('\0')' for char_type = char (common on Linux). +#elif defined(_MSC_VER) +# pragma warning(push) +# pragma warning(disable : 26495) // Variable 'static_str::chars_' is uninitialized. +# pragma warning(disable : 28020) // Arithmetic overflow: Using operator '-' on a 4 byte value and then casting the result to a 8 byte value. +# pragma warning(disable : 26451) // The expression '0<=_Param_(1)&&_Param_(1)<=1-1' is not true at this call. +# pragma warning(disable : 4514) // Unreferenced inline function has been removed. +#endif + +// Checks magic_enum compiler compatibility. +#if defined(__clang__) && __clang_major__ >= 5 || defined(__GNUC__) && __GNUC__ >= 9 || defined(_MSC_VER) && _MSC_VER >= 1910 || defined(__RESHARPER__) +# undef MAGIC_ENUM_SUPPORTED +# define MAGIC_ENUM_SUPPORTED 1 +#endif + +// Checks magic_enum compiler aliases compatibility. +#if defined(__clang__) && __clang_major__ >= 5 || defined(__GNUC__) && __GNUC__ >= 9 || defined(_MSC_VER) && _MSC_VER >= 1920 +# undef MAGIC_ENUM_SUPPORTED_ALIASES +# define MAGIC_ENUM_SUPPORTED_ALIASES 1 +#endif + +// Enum value must be greater or equals than MAGIC_ENUM_RANGE_MIN. By default MAGIC_ENUM_RANGE_MIN = -128. +// If need another min range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MIN. +#if !defined(MAGIC_ENUM_RANGE_MIN) +# define MAGIC_ENUM_RANGE_MIN -128 +#endif + +// Enum value must be less or equals than MAGIC_ENUM_RANGE_MAX. By default MAGIC_ENUM_RANGE_MAX = 127. +// If need another max range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MAX. +#if !defined(MAGIC_ENUM_RANGE_MAX) +# define MAGIC_ENUM_RANGE_MAX 127 +#endif + +// Improve ReSharper C++ intellisense performance with builtins, avoiding unnecessary template instantiations. +#if defined(__RESHARPER__) +# undef MAGIC_ENUM_GET_ENUM_NAME_BUILTIN +# undef MAGIC_ENUM_GET_TYPE_NAME_BUILTIN +# if __RESHARPER__ >= 20230100 +# define MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V) __rscpp_enumerator_name(V) +# define MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(T) __rscpp_type_name() +# else +# define MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V) nullptr +# define MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(T) nullptr +# endif +#endif + +namespace magic_enum { + +// If need another optional type, define the macro MAGIC_ENUM_USING_ALIAS_OPTIONAL. +#if defined(MAGIC_ENUM_USING_ALIAS_OPTIONAL) +MAGIC_ENUM_USING_ALIAS_OPTIONAL +#else +using std::optional; +#endif + +// If need another string_view type, define the macro MAGIC_ENUM_USING_ALIAS_STRING_VIEW. +#if defined(MAGIC_ENUM_USING_ALIAS_STRING_VIEW) +MAGIC_ENUM_USING_ALIAS_STRING_VIEW +#else +using std::string_view; +#endif + +// If need another string type, define the macro MAGIC_ENUM_USING_ALIAS_STRING. +#if defined(MAGIC_ENUM_USING_ALIAS_STRING) +MAGIC_ENUM_USING_ALIAS_STRING +#else +using std::string; +#endif + +using char_type = string_view::value_type; +static_assert(std::is_same_v, "magic_enum::customize requires same string_view::value_type and string::value_type"); +static_assert([] { + if constexpr (std::is_same_v) { + constexpr const char c[] = "abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|"; + constexpr const wchar_t wc[] = L"abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|"; + static_assert(std::size(c) == std::size(wc), "magic_enum::customize identifier characters are multichars in wchar_t."); + + for (std::size_t i = 0; i < std::size(c); ++i) { + if (c[i] != wc[i]) { + return false; + } + } + } + return true; +} (), "magic_enum::customize wchar_t is not compatible with ASCII."); + +namespace customize { + +// Enum value must be in range [MAGIC_ENUM_RANGE_MIN, MAGIC_ENUM_RANGE_MAX]. By default MAGIC_ENUM_RANGE_MIN = -128, MAGIC_ENUM_RANGE_MAX = 127. +// If need another range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MIN and MAGIC_ENUM_RANGE_MAX. +// If need another range for specific enum type, add specialization enum_range for necessary enum type. +template +struct enum_range { + static constexpr int min = MAGIC_ENUM_RANGE_MIN; + static constexpr int max = MAGIC_ENUM_RANGE_MAX; +}; + +static_assert(MAGIC_ENUM_RANGE_MAX > MAGIC_ENUM_RANGE_MIN, "MAGIC_ENUM_RANGE_MAX must be greater than MAGIC_ENUM_RANGE_MIN."); + +namespace detail { + +enum class customize_tag { + default_tag, + invalid_tag, + custom_tag +}; + +} // namespace magic_enum::customize::detail + +class customize_t : public std::pair { + public: + constexpr customize_t(string_view srt) : std::pair{detail::customize_tag::custom_tag, srt} {} + constexpr customize_t(const char_type* srt) : customize_t{string_view{srt}} {} + constexpr customize_t(detail::customize_tag tag) : std::pair{tag, string_view{}} { + MAGIC_ENUM_ASSERT(tag != detail::customize_tag::custom_tag); + } +}; + +// Default customize. +inline constexpr auto default_tag = customize_t{detail::customize_tag::default_tag}; +// Invalid customize. +inline constexpr auto invalid_tag = customize_t{detail::customize_tag::invalid_tag}; + +// If need custom names for enum, add specialization enum_name for necessary enum type. +template +constexpr customize_t enum_name(E) noexcept { + return default_tag; +} + +// If need custom type name for enum, add specialization enum_type_name for necessary enum type. +template +constexpr customize_t enum_type_name() noexcept { + return default_tag; +} + +} // namespace magic_enum::customize + +namespace detail { + +template +struct supported +#if defined(MAGIC_ENUM_SUPPORTED) && MAGIC_ENUM_SUPPORTED || defined(MAGIC_ENUM_NO_CHECK_SUPPORT) + : std::true_type {}; +#else + : std::false_type {}; +#endif + +template , std::enable_if_t, int> = 0> +using enum_constant = std::integral_constant; + +template +inline constexpr bool always_false_v = false; + +template +struct has_is_flags : std::false_type {}; + +template +struct has_is_flags::is_flags)>> : std::bool_constant::is_flags)>>> {}; + +template +struct range_min : std::integral_constant {}; + +template +struct range_min::min)>> : std::integral_constant::min), customize::enum_range::min> {}; + +template +struct range_max : std::integral_constant {}; + +template +struct range_max::max)>> : std::integral_constant::max), customize::enum_range::max> {}; + +struct str_view { + const char* str_ = nullptr; + std::size_t size_ = 0; +}; + +template +class static_str { + public: + constexpr explicit static_str(str_view str) noexcept : static_str{str.str_, std::make_integer_sequence{}} { + MAGIC_ENUM_ASSERT(str.size_ == N); + } + + constexpr explicit static_str(string_view str) noexcept : static_str{str.data(), std::make_integer_sequence{}} { + MAGIC_ENUM_ASSERT(str.size() == N); + } + + constexpr const char_type* data() const noexcept { return chars_; } + + constexpr std::uint16_t size() const noexcept { return N; } + + constexpr operator string_view() const noexcept { return {data(), size()}; } + + private: + template + constexpr static_str(const char* str, std::integer_sequence) noexcept : chars_{static_cast(str[I])..., static_cast('\0')} {} + + template + constexpr static_str(string_view str, std::integer_sequence) noexcept : chars_{str[I]..., static_cast('\0')} {} + + char_type chars_[static_cast(N) + 1]; +}; + +template <> +class static_str<0> { + public: + constexpr explicit static_str() = default; + + constexpr explicit static_str(str_view) noexcept {} + + constexpr explicit static_str(string_view) noexcept {} + + constexpr const char_type* data() const noexcept { return nullptr; } + + constexpr std::uint16_t size() const noexcept { return 0; } + + constexpr operator string_view() const noexcept { return {}; } +}; + +template > +class case_insensitive { + static constexpr char_type to_lower(char_type c) noexcept { + return (c >= static_cast('A') && c <= static_cast('Z')) ? static_cast(c + (static_cast('a') - static_cast('A'))) : c; + } + + public: + template + constexpr auto operator()(L lhs, R rhs) const noexcept -> std::enable_if_t, char_type> && std::is_same_v, char_type>, bool> { + return Op{}(to_lower(lhs), to_lower(rhs)); + } +}; + +constexpr std::size_t find(string_view str, char_type c) noexcept { +#if defined(__clang__) && __clang_major__ < 9 && defined(__GLIBCXX__) || defined(_MSC_VER) && _MSC_VER < 1920 && !defined(__clang__) +// https://stackoverflow.com/questions/56484834/constexpr-stdstring-viewfind-last-of-doesnt-work-on-clang-8-with-libstdc +// https://developercommunity.visualstudio.com/content/problem/360432/vs20178-regression-c-failed-in-test.html + constexpr bool workaround = true; +#else + constexpr bool workaround = false; +#endif + + if constexpr (workaround) { + for (std::size_t i = 0; i < str.size(); ++i) { + if (str[i] == c) { + return i; + } + } + + return string_view::npos; + } else { + return str.find(c); + } +} + +template +constexpr bool is_default_predicate() noexcept { + return std::is_same_v, std::equal_to> || + std::is_same_v, std::equal_to<>>; +} + +template +constexpr bool is_nothrow_invocable() { + return is_default_predicate() || + std::is_nothrow_invocable_r_v; +} + +template +constexpr bool cmp_equal(string_view lhs, string_view rhs, [[maybe_unused]] BinaryPredicate&& p) noexcept(is_nothrow_invocable()) { +#if defined(_MSC_VER) && _MSC_VER < 1920 && !defined(__clang__) + // https://developercommunity.visualstudio.com/content/problem/360432/vs20178-regression-c-failed-in-test.html + // https://developercommunity.visualstudio.com/content/problem/232218/c-constexpr-string-view.html + constexpr bool workaround = true; +#else + constexpr bool workaround = false; +#endif + + if constexpr (!is_default_predicate() || workaround) { + if (lhs.size() != rhs.size()) { + return false; + } + + const auto size = lhs.size(); + for (std::size_t i = 0; i < size; ++i) { + if (!p(lhs[i], rhs[i])) { + return false; + } + } + + return true; + } else { + return lhs == rhs; + } +} + +template +constexpr bool cmp_less(L lhs, R rhs) noexcept { + static_assert(std::is_integral_v && std::is_integral_v, "magic_enum::detail::cmp_less requires integral type."); + + if constexpr (std::is_signed_v == std::is_signed_v) { + // If same signedness (both signed or both unsigned). + return lhs < rhs; + } else if constexpr (std::is_same_v) { // bool special case + return static_cast(lhs) < rhs; + } else if constexpr (std::is_same_v) { // bool special case + return lhs < static_cast(rhs); + } else if constexpr (std::is_signed_v) { + // If 'right' is negative, then result is 'false', otherwise cast & compare. + return rhs > 0 && lhs < static_cast>(rhs); + } else { + // If 'left' is negative, then result is 'true', otherwise cast & compare. + return lhs < 0 || static_cast>(lhs) < rhs; + } +} + +template +constexpr I log2(I value) noexcept { + static_assert(std::is_integral_v, "magic_enum::detail::log2 requires integral type."); + + if constexpr (std::is_same_v) { // bool special case + return MAGIC_ENUM_ASSERT(false), value; + } else { + auto ret = I{0}; + for (; value > I{1}; value >>= I{1}, ++ret) {} + + return ret; + } +} + +#if defined(__cpp_lib_array_constexpr) && __cpp_lib_array_constexpr >= 201603L +# define MAGIC_ENUM_ARRAY_CONSTEXPR 1 +#else +template +constexpr std::array, N> to_array(T (&a)[N], std::index_sequence) noexcept { + return {{a[I]...}}; +} +#endif + +template +inline constexpr bool is_enum_v = std::is_enum_v && std::is_same_v>; + +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + + if constexpr (supported::value) { +#if defined(MAGIC_ENUM_GET_TYPE_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(E); + constexpr auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +#elif defined(__clang__) + str_view name; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else { + name.size_ = sizeof(__PRETTY_FUNCTION__) - 36; + name.str_ = __PRETTY_FUNCTION__ + 34; + } +#elif defined(__GNUC__) + auto name = str_view{__PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 1}; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else if (name.str_[name.size_ - 1] == ']') { + name.size_ -= 50; + name.str_ += 49; + } else { + name.size_ -= 40; + name.str_ += 37; + } +#elif defined(_MSC_VER) + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + str_view name; + name.str_ = __FUNCSIG__; + name.str_ += 40; + name.size_ += sizeof(__FUNCSIG__) - 57; +#else + auto name = str_view{}; +#endif + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + return name; + } else { + return str_view{}; // Unsupported compiler or Invalid customize. + } +} + +template +constexpr auto type_name() noexcept { + [[maybe_unused]] constexpr auto custom = customize::enum_type_name(); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return static_str{name}; + } else if constexpr (custom.first == customize::detail::customize_tag::invalid_tag) { + return static_str<0>{}; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { + constexpr auto name = n(); + return static_str{name}; + } else { + static_assert(always_false_v, "magic_enum::customize invalid."); + } +} + +template +inline constexpr auto type_name_v = type_name(); + +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + + if constexpr (supported::value) { +#if defined(MAGIC_ENUM_GET_ENUM_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V); + auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +#elif defined(__clang__) + str_view name; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else { + name.size_ = sizeof(__PRETTY_FUNCTION__) - 36; + name.str_ = __PRETTY_FUNCTION__ + 34; + } + if (name.size_ > 22 && name.str_[0] == '(' && name.str_[1] == 'a' && name.str_[10] == ' ' && name.str_[22] == ':') { + name.size_ -= 23; + name.str_ += 23; + } + if (name.str_[0] == '(' || name.str_[0] == '-' || (name.str_[0] >= '0' && name.str_[0] <= '9')) { + name = str_view{}; + } +#elif defined(__GNUC__) + auto name = str_view{__PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 1}; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else if (name.str_[name.size_ - 1] == ']') { + name.size_ -= 55; + name.str_ += 54; + } else { + name.size_ -= 40; + name.str_ += 37; + } + if (name.str_[0] == '(') { + name = str_view{}; + } +#elif defined(_MSC_VER) + str_view name; + if ((__FUNCSIG__[5] == '_' && __FUNCSIG__[35] != '(') || (__FUNCSIG__[5] == 'c' && __FUNCSIG__[41] != '(')) { + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + name.str_ = __FUNCSIG__; + name.str_ += 35; + name.size_ = sizeof(__FUNCSIG__) - 52; + } +#else + auto name = str_view{}; +#endif + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + return name; + } else { + return str_view{}; // Unsupported compiler or Invalid customize. + } +} + +#if defined(_MSC_VER) && !defined(__clang__) && _MSC_VER < 1920 +# define MAGIC_ENUM_VS_2017_WORKAROUND 1 +#endif + +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + +# if defined(MAGIC_ENUM_GET_ENUM_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V); + auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +# else + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + str_view name; + name.str_ = __FUNCSIG__; + name.size_ = sizeof(__FUNCSIG__) - 17; + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ',' || name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + if (name.str_[0] == '(' || name.str_[0] == '-' || (name.str_[0] >= '0' && name.str_[0] <= '9')) { + name = str_view{}; + } + return name; +# endif +} +#endif + +template +constexpr auto enum_name() noexcept { + [[maybe_unused]] constexpr auto custom = customize::enum_name(V); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return static_str{name}; + } else if constexpr (custom.first == customize::detail::customize_tag::invalid_tag) { + return static_str<0>{}; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) + constexpr auto name = n(); +#else + constexpr auto name = n(); +#endif + return static_str{name}; + } else { + static_assert(always_false_v, "magic_enum::customize invalid."); + } +} + +template +inline constexpr auto enum_name_v = enum_name(); + +template +constexpr bool is_valid() noexcept { +#if defined(__clang__) && __clang_major__ >= 16 + // https://reviews.llvm.org/D130058, https://reviews.llvm.org/D131307 + constexpr E v = __builtin_bit_cast(E, V); +#else + constexpr E v = static_cast(V); +#endif + [[maybe_unused]] constexpr auto custom = customize::enum_name(v); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return name.size() != 0; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) + return n().size_ != 0; +#else + return n().size_ != 0; +#endif + } else { + return false; + } +} + +enum class enum_subtype { + common, + flags +}; + +template > +constexpr U ualue(std::size_t i) noexcept { + if constexpr (std::is_same_v) { // bool special case + static_assert(O == 0, "magic_enum::detail::ualue requires valid offset."); + + return static_cast(i); + } else if constexpr (S == enum_subtype::flags) { + return static_cast(U{1} << static_cast(static_cast(i) + O)); + } else { + return static_cast(static_cast(i) + O); + } +} + +template > +constexpr E value(std::size_t i) noexcept { + return static_cast(ualue(i)); +} + +template > +constexpr int reflected_min() noexcept { + if constexpr (S == enum_subtype::flags) { + return 0; + } else { + constexpr auto lhs = range_min::value; + constexpr auto rhs = (std::numeric_limits::min)(); + + if constexpr (cmp_less(rhs, lhs)) { + return lhs; + } else { + return rhs; + } + } +} + +template > +constexpr int reflected_max() noexcept { + if constexpr (S == enum_subtype::flags) { + return std::numeric_limits::digits - 1; + } else { + constexpr auto lhs = range_max::value; + constexpr auto rhs = (std::numeric_limits::max)(); + + if constexpr (cmp_less(lhs, rhs)) { + return lhs; + } else { + return rhs; + } + } +} + +#define MAGIC_ENUM_FOR_EACH_256(T) \ + T( 0)T( 1)T( 2)T( 3)T( 4)T( 5)T( 6)T( 7)T( 8)T( 9)T( 10)T( 11)T( 12)T( 13)T( 14)T( 15)T( 16)T( 17)T( 18)T( 19)T( 20)T( 21)T( 22)T( 23)T( 24)T( 25)T( 26)T( 27)T( 28)T( 29)T( 30)T( 31) \ + T( 32)T( 33)T( 34)T( 35)T( 36)T( 37)T( 38)T( 39)T( 40)T( 41)T( 42)T( 43)T( 44)T( 45)T( 46)T( 47)T( 48)T( 49)T( 50)T( 51)T( 52)T( 53)T( 54)T( 55)T( 56)T( 57)T( 58)T( 59)T( 60)T( 61)T( 62)T( 63) \ + T( 64)T( 65)T( 66)T( 67)T( 68)T( 69)T( 70)T( 71)T( 72)T( 73)T( 74)T( 75)T( 76)T( 77)T( 78)T( 79)T( 80)T( 81)T( 82)T( 83)T( 84)T( 85)T( 86)T( 87)T( 88)T( 89)T( 90)T( 91)T( 92)T( 93)T( 94)T( 95) \ + T( 96)T( 97)T( 98)T( 99)T(100)T(101)T(102)T(103)T(104)T(105)T(106)T(107)T(108)T(109)T(110)T(111)T(112)T(113)T(114)T(115)T(116)T(117)T(118)T(119)T(120)T(121)T(122)T(123)T(124)T(125)T(126)T(127) \ + T(128)T(129)T(130)T(131)T(132)T(133)T(134)T(135)T(136)T(137)T(138)T(139)T(140)T(141)T(142)T(143)T(144)T(145)T(146)T(147)T(148)T(149)T(150)T(151)T(152)T(153)T(154)T(155)T(156)T(157)T(158)T(159) \ + T(160)T(161)T(162)T(163)T(164)T(165)T(166)T(167)T(168)T(169)T(170)T(171)T(172)T(173)T(174)T(175)T(176)T(177)T(178)T(179)T(180)T(181)T(182)T(183)T(184)T(185)T(186)T(187)T(188)T(189)T(190)T(191) \ + T(192)T(193)T(194)T(195)T(196)T(197)T(198)T(199)T(200)T(201)T(202)T(203)T(204)T(205)T(206)T(207)T(208)T(209)T(210)T(211)T(212)T(213)T(214)T(215)T(216)T(217)T(218)T(219)T(220)T(221)T(222)T(223) \ + T(224)T(225)T(226)T(227)T(228)T(229)T(230)T(231)T(232)T(233)T(234)T(235)T(236)T(237)T(238)T(239)T(240)T(241)T(242)T(243)T(244)T(245)T(246)T(247)T(248)T(249)T(250)T(251)T(252)T(253)T(254)T(255) + +template +constexpr void valid_count(bool* valid, std::size_t& count) noexcept { +#define MAGIC_ENUM_V(O) \ + if constexpr ((I + O) < Size) { \ + if constexpr (is_valid(I + O)>()) { \ + valid[I + O] = true; \ + ++count; \ + } \ + } + + MAGIC_ENUM_FOR_EACH_256(MAGIC_ENUM_V) + + if constexpr ((I + 256) < Size) { + valid_count(valid, count); + } +#undef MAGIC_ENUM_V +} + +template +struct valid_count_t { + std::size_t count = 0; + bool valid[N] = {}; +}; + +template +constexpr auto valid_count() noexcept { + valid_count_t vc; + valid_count(vc.valid, vc.count); + return vc; +} + +template +constexpr auto values() noexcept { + constexpr auto vc = valid_count(); + + if constexpr (vc.count > 0) { +#if defined(MAGIC_ENUM_ARRAY_CONSTEXPR) + std::array values = {}; +#else + E values[vc.count] = {}; +#endif + for (std::size_t i = 0, v = 0; v < vc.count; ++i) { + if (vc.valid[i]) { + values[v++] = value(i); + } + } +#if defined(MAGIC_ENUM_ARRAY_CONSTEXPR) + return values; +#else + return to_array(values, std::make_index_sequence{}); +#endif + } else { + return std::array{}; + } +} + +template > +constexpr auto values() noexcept { + constexpr auto min = reflected_min(); + constexpr auto max = reflected_max(); + constexpr auto range_size = max - min + 1; + static_assert(range_size > 0, "magic_enum::enum_range requires valid size."); + + return values(); +} + +template > +constexpr enum_subtype subtype(std::true_type) noexcept { + if constexpr (std::is_same_v) { // bool special case + return enum_subtype::common; + } else if constexpr (has_is_flags::value) { + return customize::enum_range::is_flags ? enum_subtype::flags : enum_subtype::common; + } else { +#if defined(MAGIC_ENUM_AUTO_IS_FLAGS) + constexpr auto flags_values = values(); + constexpr auto default_values = values(); + if (flags_values.size() == 0 || default_values.size() > flags_values.size()) { + return enum_subtype::common; + } + for (std::size_t i = 0; i < default_values.size(); ++i) { + const auto v = static_cast(default_values[i]); + if (v != 0 && (v & (v - 1)) != 0) { + return enum_subtype::common; + } + } + return enum_subtype::flags; +#else + return enum_subtype::common; +#endif + } +} + +template +constexpr enum_subtype subtype(std::false_type) noexcept { + // For non-enum type return default common subtype. + return enum_subtype::common; +} + +template > +inline constexpr auto subtype_v = subtype(std::is_enum{}); + +template +inline constexpr auto values_v = values(); + +template > +using values_t = decltype((values_v)); + +template +inline constexpr auto count_v = values_v.size(); + +template > +inline constexpr auto min_v = (count_v > 0) ? static_cast(values_v.front()) : U{0}; + +template > +inline constexpr auto max_v = (count_v > 0) ? static_cast(values_v.back()) : U{0}; + +template +constexpr auto names(std::index_sequence) noexcept { + constexpr auto names = std::array{{enum_name_v[I]>...}}; + return names; +} + +template +inline constexpr auto names_v = names(std::make_index_sequence>{}); + +template > +using names_t = decltype((names_v)); + +template +constexpr auto entries(std::index_sequence) noexcept { + constexpr auto entries = std::array, sizeof...(I)>{{{values_v[I], enum_name_v[I]>}...}}; + return entries; +} + +template +inline constexpr auto entries_v = entries(std::make_index_sequence>{}); + +template > +using entries_t = decltype((entries_v)); + +template > +constexpr bool is_sparse() noexcept { + if constexpr (count_v == 0) { + return false; + } else if constexpr (std::is_same_v) { // bool special case + return false; + } else { + constexpr auto max = (S == enum_subtype::flags) ? log2(max_v) : max_v; + constexpr auto min = (S == enum_subtype::flags) ? log2(min_v) : min_v; + constexpr auto range_size = max - min + 1; + + return range_size != count_v; + } +} + +template > +inline constexpr bool is_sparse_v = is_sparse(); + +template +struct is_reflected +#if defined(MAGIC_ENUM_NO_CHECK_REFLECTED_ENUM) + : std::true_type {}; +#else + : std::bool_constant && (count_v != 0)> {}; +#endif + +template +inline constexpr bool is_reflected_v = is_reflected, S>{}; + +template +struct enable_if_enum {}; + +template +struct enable_if_enum { + using type = R; + static_assert(supported::value, "magic_enum unsupported compiler (https://github.com/Neargye/magic_enum#compiler-compatibility)."); +}; + +template , typename D = std::decay_t> +using enable_if_t = typename enable_if_enum && std::is_invocable_r_v, R>::type; + +template >, int> = 0> +using enum_concept = T; + +template > +struct is_scoped_enum : std::false_type {}; + +template +struct is_scoped_enum : std::bool_constant>> {}; + +template > +struct is_unscoped_enum : std::false_type {}; + +template +struct is_unscoped_enum : std::bool_constant>> {}; + +template >> +struct underlying_type {}; + +template +struct underlying_type : std::underlying_type> {}; + +#if defined(MAGIC_ENUM_ENABLE_HASH) || defined(MAGIC_ENUM_ENABLE_HASH_SWITCH) + +template +struct constexpr_hash_t; + +template +struct constexpr_hash_t>> { + constexpr auto operator()(Value value) const noexcept { + using U = typename underlying_type::type; + if constexpr (std::is_same_v) { // bool special case + return static_cast(value); + } else { + return static_cast(value); + } + } + using secondary_hash = constexpr_hash_t; +}; + +template +struct constexpr_hash_t>> { + static constexpr std::uint32_t crc_table[256] { + 0x00000000L, 0x77073096L, 0xee0e612cL, 0x990951baL, 0x076dc419L, 0x706af48fL, 0xe963a535L, 0x9e6495a3L, + 0x0edb8832L, 0x79dcb8a4L, 0xe0d5e91eL, 0x97d2d988L, 0x09b64c2bL, 0x7eb17cbdL, 0xe7b82d07L, 0x90bf1d91L, + 0x1db71064L, 0x6ab020f2L, 0xf3b97148L, 0x84be41deL, 0x1adad47dL, 0x6ddde4ebL, 0xf4d4b551L, 0x83d385c7L, + 0x136c9856L, 0x646ba8c0L, 0xfd62f97aL, 0x8a65c9ecL, 0x14015c4fL, 0x63066cd9L, 0xfa0f3d63L, 0x8d080df5L, + 0x3b6e20c8L, 0x4c69105eL, 0xd56041e4L, 0xa2677172L, 0x3c03e4d1L, 0x4b04d447L, 0xd20d85fdL, 0xa50ab56bL, + 0x35b5a8faL, 0x42b2986cL, 0xdbbbc9d6L, 0xacbcf940L, 0x32d86ce3L, 0x45df5c75L, 0xdcd60dcfL, 0xabd13d59L, + 0x26d930acL, 0x51de003aL, 0xc8d75180L, 0xbfd06116L, 0x21b4f4b5L, 0x56b3c423L, 0xcfba9599L, 0xb8bda50fL, + 0x2802b89eL, 0x5f058808L, 0xc60cd9b2L, 0xb10be924L, 0x2f6f7c87L, 0x58684c11L, 0xc1611dabL, 0xb6662d3dL, + 0x76dc4190L, 0x01db7106L, 0x98d220bcL, 0xefd5102aL, 0x71b18589L, 0x06b6b51fL, 0x9fbfe4a5L, 0xe8b8d433L, + 0x7807c9a2L, 0x0f00f934L, 0x9609a88eL, 0xe10e9818L, 0x7f6a0dbbL, 0x086d3d2dL, 0x91646c97L, 0xe6635c01L, + 0x6b6b51f4L, 0x1c6c6162L, 0x856530d8L, 0xf262004eL, 0x6c0695edL, 0x1b01a57bL, 0x8208f4c1L, 0xf50fc457L, + 0x65b0d9c6L, 0x12b7e950L, 0x8bbeb8eaL, 0xfcb9887cL, 0x62dd1ddfL, 0x15da2d49L, 0x8cd37cf3L, 0xfbd44c65L, + 0x4db26158L, 0x3ab551ceL, 0xa3bc0074L, 0xd4bb30e2L, 0x4adfa541L, 0x3dd895d7L, 0xa4d1c46dL, 0xd3d6f4fbL, + 0x4369e96aL, 0x346ed9fcL, 0xad678846L, 0xda60b8d0L, 0x44042d73L, 0x33031de5L, 0xaa0a4c5fL, 0xdd0d7cc9L, + 0x5005713cL, 0x270241aaL, 0xbe0b1010L, 0xc90c2086L, 0x5768b525L, 0x206f85b3L, 0xb966d409L, 0xce61e49fL, + 0x5edef90eL, 0x29d9c998L, 0xb0d09822L, 0xc7d7a8b4L, 0x59b33d17L, 0x2eb40d81L, 0xb7bd5c3bL, 0xc0ba6cadL, + 0xedb88320L, 0x9abfb3b6L, 0x03b6e20cL, 0x74b1d29aL, 0xead54739L, 0x9dd277afL, 0x04db2615L, 0x73dc1683L, + 0xe3630b12L, 0x94643b84L, 0x0d6d6a3eL, 0x7a6a5aa8L, 0xe40ecf0bL, 0x9309ff9dL, 0x0a00ae27L, 0x7d079eb1L, + 0xf00f9344L, 0x8708a3d2L, 0x1e01f268L, 0x6906c2feL, 0xf762575dL, 0x806567cbL, 0x196c3671L, 0x6e6b06e7L, + 0xfed41b76L, 0x89d32be0L, 0x10da7a5aL, 0x67dd4accL, 0xf9b9df6fL, 0x8ebeeff9L, 0x17b7be43L, 0x60b08ed5L, + 0xd6d6a3e8L, 0xa1d1937eL, 0x38d8c2c4L, 0x4fdff252L, 0xd1bb67f1L, 0xa6bc5767L, 0x3fb506ddL, 0x48b2364bL, + 0xd80d2bdaL, 0xaf0a1b4cL, 0x36034af6L, 0x41047a60L, 0xdf60efc3L, 0xa867df55L, 0x316e8eefL, 0x4669be79L, + 0xcb61b38cL, 0xbc66831aL, 0x256fd2a0L, 0x5268e236L, 0xcc0c7795L, 0xbb0b4703L, 0x220216b9L, 0x5505262fL, + 0xc5ba3bbeL, 0xb2bd0b28L, 0x2bb45a92L, 0x5cb36a04L, 0xc2d7ffa7L, 0xb5d0cf31L, 0x2cd99e8bL, 0x5bdeae1dL, + 0x9b64c2b0L, 0xec63f226L, 0x756aa39cL, 0x026d930aL, 0x9c0906a9L, 0xeb0e363fL, 0x72076785L, 0x05005713L, + 0x95bf4a82L, 0xe2b87a14L, 0x7bb12baeL, 0x0cb61b38L, 0x92d28e9bL, 0xe5d5be0dL, 0x7cdcefb7L, 0x0bdbdf21L, + 0x86d3d2d4L, 0xf1d4e242L, 0x68ddb3f8L, 0x1fda836eL, 0x81be16cdL, 0xf6b9265bL, 0x6fb077e1L, 0x18b74777L, + 0x88085ae6L, 0xff0f6a70L, 0x66063bcaL, 0x11010b5cL, 0x8f659effL, 0xf862ae69L, 0x616bffd3L, 0x166ccf45L, + 0xa00ae278L, 0xd70dd2eeL, 0x4e048354L, 0x3903b3c2L, 0xa7672661L, 0xd06016f7L, 0x4969474dL, 0x3e6e77dbL, + 0xaed16a4aL, 0xd9d65adcL, 0x40df0b66L, 0x37d83bf0L, 0xa9bcae53L, 0xdebb9ec5L, 0x47b2cf7fL, 0x30b5ffe9L, + 0xbdbdf21cL, 0xcabac28aL, 0x53b39330L, 0x24b4a3a6L, 0xbad03605L, 0xcdd70693L, 0x54de5729L, 0x23d967bfL, + 0xb3667a2eL, 0xc4614ab8L, 0x5d681b02L, 0x2a6f2b94L, 0xb40bbe37L, 0xc30c8ea1L, 0x5a05df1bL, 0x2d02ef8dL + }; + constexpr std::uint32_t operator()(string_view value) const noexcept { + auto crc = static_cast(0xffffffffL); + for (const auto c : value) { + crc = (crc >> 8) ^ crc_table[(crc ^ static_cast(c)) & 0xff]; + } + return crc ^ 0xffffffffL; + } + + struct secondary_hash { + constexpr std::uint32_t operator()(string_view value) const noexcept { + auto acc = static_cast(2166136261ULL); + for (const auto c : value) { + acc = ((acc ^ static_cast(c)) * static_cast(16777619ULL)) & (std::numeric_limits::max)(); + } + return static_cast(acc); + } + }; +}; + +template +inline constexpr Hash hash_v{}; + +template +constexpr auto calculate_cases(std::size_t Page) noexcept { + constexpr std::array values = *GlobValues; + constexpr std::size_t size = values.size(); + + using switch_t = std::invoke_result_t; + static_assert(std::is_integral_v && !std::is_same_v); + const std::size_t values_to = (std::min)(static_cast(256), size - Page); + + std::array result{}; + auto fill = result.begin(); + { + auto first = values.begin() + static_cast(Page); + auto last = values.begin() + static_cast(Page + values_to); + while (first != last) { + *fill++ = hash_v(*first++); + } + } + + // dead cases, try to avoid case collisions + for (switch_t last_value = result[values_to - 1]; fill != result.end() && last_value != (std::numeric_limits::max)(); *fill++ = ++last_value) { + } + + { + auto it = result.begin(); + auto last_value = (std::numeric_limits::min)(); + for (; fill != result.end(); *fill++ = last_value++) { + while (last_value == *it) { + ++last_value, ++it; + } + } + } + + return result; +} + +template +constexpr R invoke_r(F&& f, Args&&... args) noexcept(std::is_nothrow_invocable_r_v) { + if constexpr (std::is_void_v) { + std::forward(f)(std::forward(args)...); + } else { + return static_cast(std::forward(f)(std::forward(args)...)); + } +} + +enum class case_call_t { + index, + value +}; + +template +inline constexpr auto default_result_type_lambda = []() noexcept(std::is_nothrow_default_constructible_v) { return T{}; }; + +template <> +inline constexpr auto default_result_type_lambda = []() noexcept {}; + +template +constexpr bool has_duplicate() noexcept { + using value_t = std::decay_t; + using hash_value_t = std::invoke_result_t; + std::arraysize()> hashes{}; + std::size_t size = 0; + for (auto elem : *Arr) { + hashes[size] = hash_v(elem); + for (auto i = size++; i > 0; --i) { + if (hashes[i] < hashes[i - 1]) { + auto tmp = hashes[i]; + hashes[i] = hashes[i - 1]; + hashes[i - 1] = tmp; + } else if (hashes[i] == hashes[i - 1]) { + return false; + } else { + break; + } + } + } + return true; +} + +#define MAGIC_ENUM_CASE(val) \ + case cases[val]: \ + if constexpr ((val) + Page < size) { \ + if (!pred(values[val + Page], searched)) { \ + break; \ + } \ + if constexpr (CallValue == case_call_t::index) { \ + if constexpr (std::is_invocable_r_v>) { \ + return detail::invoke_r(std::forward(lambda), std::integral_constant{}); \ + } else if constexpr (std::is_invocable_v>) { \ + MAGIC_ENUM_ASSERT(false && "magic_enum::detail::constexpr_switch wrong result type."); \ + } \ + } else if constexpr (CallValue == case_call_t::value) { \ + if constexpr (std::is_invocable_r_v>) { \ + return detail::invoke_r(std::forward(lambda), enum_constant{}); \ + } else if constexpr (std::is_invocable_r_v>) { \ + MAGIC_ENUM_ASSERT(false && "magic_enum::detail::constexpr_switch wrong result type."); \ + } \ + } \ + break; \ + } else [[fallthrough]]; + +template ::value_type>, + typename BinaryPredicate = std::equal_to<>, + typename Lambda, + typename ResultGetterType> +constexpr decltype(auto) constexpr_switch( + Lambda&& lambda, + typename std::decay_t::value_type searched, + ResultGetterType&& def, + BinaryPredicate&& pred = {}) { + using result_t = std::invoke_result_t; + using hash_t = std::conditional_t(), Hash, typename Hash::secondary_hash>; + static_assert(has_duplicate(), "magic_enum::detail::constexpr_switch duplicated hash found, please report it: https://github.com/Neargye/magic_enum/issues."); + constexpr std::array values = *GlobValues; + constexpr std::size_t size = values.size(); + constexpr std::array cases = calculate_cases(Page); + + switch (hash_v(searched)) { + MAGIC_ENUM_FOR_EACH_256(MAGIC_ENUM_CASE) + default: + if constexpr (size > 256 + Page) { + return constexpr_switch(std::forward(lambda), searched, std::forward(def)); + } + break; + } + return def(); +} + +#undef MAGIC_ENUM_CASE + +#endif + +} // namespace magic_enum::detail + +// Checks is magic_enum supported compiler. +inline constexpr bool is_magic_enum_supported = detail::supported::value; + +template +using Enum = detail::enum_concept; + +// Checks whether T is an Unscoped enumeration type. +// Provides the member constant value which is equal to true, if T is an [Unscoped enumeration](https://en.cppreference.com/w/cpp/language/enum#Unscoped_enumeration) type. Otherwise, value is equal to false. +template +struct is_unscoped_enum : detail::is_unscoped_enum {}; + +template +inline constexpr bool is_unscoped_enum_v = is_unscoped_enum::value; + +// Checks whether T is an Scoped enumeration type. +// Provides the member constant value which is equal to true, if T is an [Scoped enumeration](https://en.cppreference.com/w/cpp/language/enum#Scoped_enumerations) type. Otherwise, value is equal to false. +template +struct is_scoped_enum : detail::is_scoped_enum {}; + +template +inline constexpr bool is_scoped_enum_v = is_scoped_enum::value; + +// If T is a complete enumeration type, provides a member typedef type that names the underlying type of T. +// Otherwise, if T is not an enumeration type, there is no member type. Otherwise (T is an incomplete enumeration type), the program is ill-formed. +template +struct underlying_type : detail::underlying_type {}; + +template +using underlying_type_t = typename underlying_type::type; + +template +using enum_constant = detail::enum_constant; + +// Returns type name of enum. +template +[[nodiscard]] constexpr auto enum_type_name() noexcept -> detail::enable_if_t { + constexpr string_view name = detail::type_name_v>; + static_assert(!name.empty(), "magic_enum::enum_type_name enum type does not have a name."); + + return name; +} + +// Returns number of enum values. +template > +[[nodiscard]] constexpr auto enum_count() noexcept -> detail::enable_if_t { + return detail::count_v, S>; +} + +// Returns enum value at specified index. +// No bounds checking is performed: the behavior is undefined if index >= number of enum values. +template > +[[nodiscard]] constexpr auto enum_value(std::size_t index) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v) { + return MAGIC_ENUM_ASSERT(index < detail::count_v), detail::values_v[index]; + } else { + constexpr auto min = (S == detail::enum_subtype::flags) ? detail::log2(detail::min_v) : detail::min_v; + + return MAGIC_ENUM_ASSERT(index < detail::count_v), detail::value(index); + } +} + +// Returns enum value at specified index. +template > +[[nodiscard]] constexpr auto enum_value() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + static_assert(I < detail::count_v, "magic_enum::enum_value out of range."); + + return enum_value(I); +} + +// Returns std::array with enum values, sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_values() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::values_v; +} + +// Returns integer value from enum value. +template +[[nodiscard]] constexpr auto enum_integer(E value) noexcept -> detail::enable_if_t> { + return static_cast>(value); +} + +// Returns underlying value from enum value. +template +[[nodiscard]] constexpr auto enum_underlying(E value) noexcept -> detail::enable_if_t> { + return static_cast>(value); +} + +// Obtains index in enum values from enum value. +// Returns optional with index. +template > +[[nodiscard]] constexpr auto enum_index(E value) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + using U = underlying_type_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v || (S == detail::enum_subtype::flags)) { +#if defined(MAGIC_ENUM_ENABLE_HASH) + return detail::constexpr_switch<&detail::values_v, detail::case_call_t::index>( + [](std::size_t i) { return optional{i}; }, + value, + detail::default_result_type_lambda>); +#else + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (enum_value(i) == value) { + return i; + } + } + return {}; // Invalid value or out of range. +#endif + } else { + const auto v = static_cast(value); + if (v >= detail::min_v && v <= detail::max_v) { + return static_cast(v - detail::min_v); + } + return {}; // Invalid value or out of range. + } +} + +// Obtains index in enum values from enum value. +// Returns optional with index. +template +[[nodiscard]] constexpr auto enum_index(E value) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return enum_index(value); +} + +// Obtains index in enum values from static storage enum variable. +template >> +[[nodiscard]] constexpr auto enum_index() noexcept -> detail::enable_if_t {\ + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + constexpr auto index = enum_index(V); + static_assert(index, "magic_enum::enum_index enum value does not have a index."); + + return *index; +} + +// Returns name from static storage enum variable. +// This version is much lighter on the compile times and is not restricted to the enum_range limitation. +template +[[nodiscard]] constexpr auto enum_name() noexcept -> detail::enable_if_t { + constexpr string_view name = detail::enum_name_v, V>; + static_assert(!name.empty(), "magic_enum::enum_name enum value does not have a name."); + + return name; +} + +// Returns name from enum value. +// If enum value does not have name or value out of range, returns empty string. +template > +[[nodiscard]] constexpr auto enum_name(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if (const auto i = enum_index(value)) { + return detail::names_v[*i]; + } + return {}; +} + +// Returns name from enum value. +// If enum value does not have name or value out of range, returns empty string. +template +[[nodiscard]] constexpr auto enum_name(E value) -> detail::enable_if_t { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return enum_name(value); +} + +// Returns std::array with names, sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_names() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::names_v; +} + +// Returns std::array with pairs (value, name), sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_entries() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::entries_v; +} + +// Allows you to write magic_enum::enum_cast("bar", magic_enum::case_insensitive); +inline constexpr auto case_insensitive = detail::case_insensitive<>{}; + +// Obtains enum value from integer value. +// Returns optional with enum value. +template > +[[nodiscard]] constexpr auto enum_cast(underlying_type_t value) noexcept -> detail::enable_if_t>> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v || (S == detail::enum_subtype::flags)) { +#if defined(MAGIC_ENUM_ENABLE_HASH) + return detail::constexpr_switch<&detail::values_v, detail::case_call_t::value>( + [](D v) { return optional{v}; }, + static_cast(value), + detail::default_result_type_lambda>); +#else + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (value == static_cast>(enum_value(i))) { + return static_cast(value); + } + } + return {}; // Invalid value or out of range. +#endif + } else { + if (value >= detail::min_v && value <= detail::max_v) { + return static_cast(value); + } + return {}; // Invalid value or out of range. + } +} + +// Obtains enum value from name. +// Returns optional with enum value. +template , typename BinaryPredicate = std::equal_to<>> +[[nodiscard]] constexpr auto enum_cast(string_view value, [[maybe_unused]] BinaryPredicate p = {}) noexcept(detail::is_nothrow_invocable()) -> detail::enable_if_t>, BinaryPredicate> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + +#if defined(MAGIC_ENUM_ENABLE_HASH) + if constexpr (detail::is_default_predicate()) { + return detail::constexpr_switch<&detail::names_v, detail::case_call_t::index>( + [](std::size_t i) { return optional{detail::values_v[i]}; }, + value, + detail::default_result_type_lambda>, + [&p](string_view lhs, string_view rhs) { return detail::cmp_equal(lhs, rhs, p); }); + } +#endif + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (detail::cmp_equal(value, detail::names_v[i], p)) { + return enum_value(i); + } + } + return {}; // Invalid value or out of range. +} + +// Checks whether enum contains value with such value. +template > +[[nodiscard]] constexpr auto enum_contains(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + using U = underlying_type_t; + + return static_cast(enum_cast(static_cast(value))); +} + +// Checks whether enum contains value with such value. +template +[[nodiscard]] constexpr auto enum_contains(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + using U = underlying_type_t; + + return static_cast(enum_cast(static_cast(value))); +} + +// Checks whether enum contains value with such integer value. +template > +[[nodiscard]] constexpr auto enum_contains(underlying_type_t value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return static_cast(enum_cast(value)); +} + +// Checks whether enum contains enumerator with such name. +template , typename BinaryPredicate = std::equal_to<>> +[[nodiscard]] constexpr auto enum_contains(string_view value, BinaryPredicate p = {}) noexcept(detail::is_nothrow_invocable()) -> detail::enable_if_t { + using D = std::decay_t; + + return static_cast(enum_cast(value, std::move(p))); +} + +// Returns true if the enum integer value is in the range of values that can be reflected. +template > +[[nodiscard]] constexpr auto enum_reflected(underlying_type_t value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + if constexpr (detail::is_reflected_v) { + constexpr auto min = detail::reflected_min(); + constexpr auto max = detail::reflected_max(); + return value >= min && value <= max; + } else { + return false; + } +} + +// Returns true if the enum value is in the range of values that can be reflected. +template > +[[nodiscard]] constexpr auto enum_reflected(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return enum_reflected(static_cast>(value)); +} + +// Returns true if the enum value is in the range of values that can be reflected. +template +[[nodiscard]] constexpr auto enum_reflected(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return enum_reflected(value); +} + +template +inline constexpr auto as_flags = AsFlags ? detail::enum_subtype::flags : detail::enum_subtype::common; + +template +inline constexpr auto as_common = AsFlags ? detail::enum_subtype::common : detail::enum_subtype::flags; + +namespace bitwise_operators { + +template = 0> +constexpr E operator~(E rhs) noexcept { + return static_cast(~static_cast>(rhs)); +} + +template = 0> +constexpr E operator|(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) | static_cast>(rhs)); +} + +template = 0> +constexpr E operator&(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) & static_cast>(rhs)); +} + +template = 0> +constexpr E operator^(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) ^ static_cast>(rhs)); +} + +template = 0> +constexpr E& operator|=(E& lhs, E rhs) noexcept { + return lhs = (lhs | rhs); +} + +template = 0> +constexpr E& operator&=(E& lhs, E rhs) noexcept { + return lhs = (lhs & rhs); +} + +template = 0> +constexpr E& operator^=(E& lhs, E rhs) noexcept { + return lhs = (lhs ^ rhs); +} + +} // namespace magic_enum::bitwise_operators + +} // namespace magic_enum + +#if defined(__clang__) +# pragma clang diagnostic pop +#elif defined(__GNUC__) +# pragma GCC diagnostic pop +#elif defined(_MSC_VER) +# pragma warning(pop) +#endif + +#undef MAGIC_ENUM_GET_ENUM_NAME_BUILTIN +#undef MAGIC_ENUM_GET_TYPE_NAME_BUILTIN +#undef MAGIC_ENUM_VS_2017_WORKAROUND +#undef MAGIC_ENUM_ARRAY_CONSTEXPR +#undef MAGIC_ENUM_FOR_EACH_256 + +#endif // NEARGYE_MAGIC_ENUM_HPP diff --git a/entry/src/main/cpp/napi_init.cpp b/entry/src/main/cpp/napi_init.cpp new file mode 100644 index 0000000..7b37889 --- /dev/null +++ b/entry/src/main/cpp/napi_init.cpp @@ -0,0 +1,163 @@ +#define LOG_TAG "NativeEntry" + +#include "Logging.hpp" +#include "NapiUtils.hpp" +#include "filemanagement/clouddiskmanager/oh_cloud_disk_manager.h" +#include "magic_enum.hpp" +#include "napi/native_api.h" + +#include +// #include + +static bool canIUse(const char *) +{ + return true; +} + +static napi_value DoAdd(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [alias, filePath] = *input; + LogInfo("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolder folder; + folder.displayNameInfo.customAlias = (char *) alias.data(); + folder.displayNameInfo.customAliasLength = alias.length(); + folder.displayNameInfo.displayNameResId = 0; + folder.state = CloudDisk_SyncFolderState::ACTIVE; + folder.path.value = strdup(filePath.c_str()); + folder.path.length = filePath.length(); + const auto ret = OH_CloudDisk_RegisterSyncFolder(&folder); + + LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeDatas[], size_t bufferLength) +{ + LogInfo("Path: %{public}s", syncFolderPath.value); + LogInfo("Length: %{public}d", bufferLength); + for (int i = 0; i < bufferLength; i++) + { + const CloudDisk_ChangeData &data = changeDatas[i]; + LogInfo(" #%{public}d, Seq: %{public}d", i, data.updateSequenceNumber); + LogInfo(" Id: %{public}s", data.fileId.value); + LogInfo(" ParentId: %{public}s", data.parentFileId.value); + LogInfo(" Relative: %{public}s", data.relativePathInfo.value); + LogInfo(" OP: %{public}s", magic_enum::enum_name(data.operationType).data()); + LogInfo(" Size: %{public}llu", data.size); + LogInfo(" MTime: %{public}llu", data.mtime); + } +} + +static napi_value DoConnect(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogInfo("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_ActiveSyncFolder(path); + LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); + + OH_CloudDisk_RegisterSyncFolderChanges(path, OnChangedCallback); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoDisconnect(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogInfo("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + OH_CloudDisk_UnregisterSyncFolderChanges(path); + const auto ret = OH_CloudDisk_DeactiveSyncFolder(path); + LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoRemove(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_UnregisterSyncFolder(path); + LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; +} + +EXTERN_C_START +static napi_value Init(napi_env env, napi_value exports) +{ + napi_property_descriptor desc[] = { + { "add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr }, + }; + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + return exports; +} +EXTERN_C_END + +static napi_module demoModule = { + .nm_version = 1, + .nm_flags = 0, + .nm_filename = nullptr, + .nm_register_func = Init, + .nm_modname = "cloudsync", + .nm_priv = ((void *) 0), + .reserved = { 0 }, +}; + +extern "C" __attribute__((constructor)) void RegisterCloudSyncModule(void) +{ + napi_module_register(&demoModule); +} diff --git a/entry/src/main/cpp/types/libcloudsync/Index.d.ts b/entry/src/main/cpp/types/libcloudsync/Index.d.ts new file mode 100644 index 0000000..9ed2d77 --- /dev/null +++ b/entry/src/main/cpp/types/libcloudsync/Index.d.ts @@ -0,0 +1,7 @@ +export const add: (alias: string, path: string) => number; + +export const remove: (path: string) => number; + +export const connect: (path: string) => number; + +export const disconnect: (path: string) => number; diff --git a/entry/src/main/cpp/types/libcloudsync/oh-package.json5 b/entry/src/main/cpp/types/libcloudsync/oh-package.json5 new file mode 100644 index 0000000..9a83d42 --- /dev/null +++ b/entry/src/main/cpp/types/libcloudsync/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libcloudsync.so", + "types": "./Index.d.ts", + "version": "1.0.0", + "description": "Please describe the basic information." +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index c057df3..071db29 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -6,6 +6,7 @@ import { GlobalStorage } from '../utils/GlobalStorage'; import { Notification } from '../utils/NotificationManager'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { deviceInfo } from '@kit.BasicServicesKit'; const TAG = 'EntryAbility'; @@ -20,8 +21,6 @@ export default class EntryAbility extends UIAbility { Logger.Error(TAG, `failed to set color mode!`); } NCommon.Initialise(this.context); - GlobalStorage.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); - await Notification.startNotification(this.context); } onDestroy() { @@ -35,8 +34,18 @@ export default class EntryAbility extends UIAbility { GlobalStorage.SandboxBasePath = context.filesDir; try { - await windowStage.loadContent('pages/Index'); - PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); + let isPc = deviceInfo.deviceType === deviceInfo.DeviceTypes.TYPE_2IN1; + let page = isPc ? 'pages/PcMainPage' : 'pages/Index'; + if (!isPc) { + GlobalStorage.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); + await Notification.startNotification(this.context); + } + await windowStage.loadContent(page); + if (isPc) { + PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_PATH, ''); + } else { + PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); + } Logger.Info(TAG, 'Succeeded in loading the content.'); } catch (e) { Logger.Error(TAG, 'Failed to load the content. Cause: ' + JSON.stringify(e) ?? ''); diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets new file mode 100644 index 0000000..71e96d1 --- /dev/null +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -0,0 +1,67 @@ +import { add, connect, disconnect, remove } from 'libcloudsync.so'; +import { Logger } from 'nextcloud'; +import { common } from '@kit.AbilityKit'; +import { fileUri, picker } from '@kit.CoreFileKit'; +import { Constants } from '../utils/Common'; + +const TAG = 'PcAbility' + +@Entry +@Component +struct PCAbility { + @StorageLink(Constants.PC_CLOUD_DISK_PATH) private path: string = ''; + + aboutToAppear(): void { + Logger.Info(TAG, `pcAbility aboutToAppear`); + } + + aboutToDisappear(): void { + disconnect(this.path); + } + + build() { + Column({ space: 10 }) { + Text(`Root Path: ${this.path}`) + + Button('select path') + .onClick(async () => { + let context = this.getUIContext().getHostContext() as common.UIAbilityContext; + let documentPicker = new picker.DocumentViewPicker(context); + let items = await documentPicker.select({ + defaultFilePathUri: 'file://docs/storage/Users/currentUser/', + maxSelectNumber: 1, + selectMode: picker.DocumentSelectMode.FOLDER + }); + if (items.length !== 1) { + Logger.Error(TAG, `???`); + return; + } + let path = new fileUri.FileUri(items[0]).path; + this.path = path; + + }) + + Button('create root dir') + .onClick(async () => { + let addResult = add("Moody 的 Nextcloud", this.path); + Logger.Info(TAG, `Add: ${addResult}`) + }) + + Button('connect') + .onClick(async () => { + connect(this.path); + }) + + Button('disconnect') + .onClick(() => { + disconnect(this.path); + }) + + Button('remove root dir') + .onClick(() => { + remove(this.path); + }) + } + .margin(16) + } +} diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index a72623c..d909e1e 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -6,4 +6,6 @@ export enum FolderViewMode { export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; + + export const PC_CLOUD_DISK_PATH = 'PC.CloudDisk.Path'; } diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 1898d94..498c340 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,5 +1,6 @@ { "src": [ - "pages/Index" + "pages/Index", + "pages/PcMainPage" ] } diff --git a/nextcloud/oh-package-lock.json5 b/nextcloud/oh-package-lock.json5 index c36f764..31c3e07 100644 --- a/nextcloud/oh-package-lock.json5 +++ b/nextcloud/oh-package-lock.json5 @@ -10,7 +10,7 @@ }, "packages": { "@ohos/axios@2.2.4": { - "name": "", + "name": "@ohos/axios", "version": "2.2.4", "integrity": "sha512-5oH753kGFwfJP1kvCnI92RRaSqRbN0b24vmNNrUV+T8+5ako98rtVQOhA7ezMPox5Z3PFWndLQTF/P/hEK/yeQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/axios/-/axios-2.2.4.har", diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 index a974b0f..5228468 100644 --- a/oh-package-lock.json5 +++ b/oh-package-lock.json5 @@ -11,14 +11,14 @@ }, "packages": { "@ohos/hamock@1.0.0": { - "name": "", + "name": "@ohos/hamock", "version": "1.0.0", "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", "registryType": "ohpm" }, "@ohos/hypium@1.0.19": { - "name": "", + "name": "@ohos/hypium", "version": "1.0.19", "integrity": "sha512-cEjDgLFCm3cWZDeRXk7agBUkPqjWxUo6AQeiu0gEkb3J8ESqlduQLSIXeo3cCsm8U/asL7iKjF85ZyOuufAGSQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.19.har", From cae839d5af5fe4a0dbca4bec5f784233452a1604 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Wed, 31 Dec 2025 13:15:53 +0800 Subject: [PATCH 03/16] napi refactor --- entry/src/main/cpp/.vscode/settings.json | 6 + entry/src/main/cpp/CMakeLists.txt | 5 +- entry/src/main/cpp/include/Logging.hpp | 18 +- entry/src/main/cpp/include/NapiCallback.hpp | 68 +++ .../main/cpp/include/NapiCallbackStorage.hpp | 35 ++ entry/src/main/cpp/include/NapiUtils.hpp | 6 +- entry/src/main/cpp/napi_init.cpp | 163 ------- entry/src/main/cpp/src/napi_init.cpp | 399 ++++++++++++++++++ .../main/cpp/types/libcloudsync/Index.d.ts | 48 ++- .../main/ets/entryability/EntryAbility.ets | 3 +- entry/src/main/ets/pages/FolderPage.ets | 12 +- entry/src/main/ets/pages/PcMainPage.ets | 165 ++++++-- entry/src/main/ets/utils/CloudSyncApi.ets | 36 ++ entry/src/main/ets/utils/Common.ets | 2 + 14 files changed, 738 insertions(+), 228 deletions(-) create mode 100644 entry/src/main/cpp/.vscode/settings.json create mode 100644 entry/src/main/cpp/include/NapiCallback.hpp create mode 100644 entry/src/main/cpp/include/NapiCallbackStorage.hpp delete mode 100644 entry/src/main/cpp/napi_init.cpp create mode 100644 entry/src/main/cpp/src/napi_init.cpp create mode 100644 entry/src/main/ets/utils/CloudSyncApi.ets diff --git a/entry/src/main/cpp/.vscode/settings.json b/entry/src/main/cpp/.vscode/settings.json new file mode 100644 index 0000000..7bda452 --- /dev/null +++ b/entry/src/main/cpp/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}/../../../.cxx/default/default/debug/arm64-v8a/", + "--query-driver=**" + ] +} diff --git a/entry/src/main/cpp/CMakeLists.txt b/entry/src/main/cpp/CMakeLists.txt index 1a3aa1c..04fbc7b 100644 --- a/entry/src/main/cpp/CMakeLists.txt +++ b/entry/src/main/cpp/CMakeLists.txt @@ -13,7 +13,10 @@ endif() include_directories(${NATIVERENDER_ROOT_PATH} ${NATIVERENDER_ROOT_PATH}/include) -add_library(cloudsync SHARED napi_init.cpp) +add_library(cloudsync + SHARED + src/napi_init.cpp +) target_link_libraries( cloudsync PUBLIC diff --git a/entry/src/main/cpp/include/Logging.hpp b/entry/src/main/cpp/include/Logging.hpp index 9654e89..3a71baf 100644 --- a/entry/src/main/cpp/include/Logging.hpp +++ b/entry/src/main/cpp/include/Logging.hpp @@ -1,13 +1,15 @@ #pragma once #define LOG_DOMAIN 0 -#ifndef LOG_TAG -#error "LOG_TAG must be defined" -#endif - #include -#define LogError(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_ERROR, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) -#define LogWarn(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_WARN, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) -#define LogInfo(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_INFO, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) -#define LogDebug(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_DEBUG, LOG_DOMAIN, LOG_TAG, "%{public}s: " fmt, __FUNCTION__, ##__VA_ARGS__) +#define LogFmt(fmt, ...) "[%{public}s:%{public}d] " fmt, __func__, __LINE__, ##__VA_ARGS__ + +#define LogE(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_ERROR, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogW(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_WARN, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogI(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_INFO, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogD(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_DEBUG, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) + +// clang-format off +#define NAPI_ERR_RET(expr, msg, ...) if ((expr) != napi_ok) { LogI(msg, ##__VA_ARGS__); return; } +// clang-format on diff --git a/entry/src/main/cpp/include/NapiCallback.hpp b/entry/src/main/cpp/include/NapiCallback.hpp new file mode 100644 index 0000000..1dc129f --- /dev/null +++ b/entry/src/main/cpp/include/NapiCallback.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +struct NapiObject +{ + public: + NapiObject() = default; + virtual ~NapiObject() = default; + virtual napi_value ToNapiValue(napi_env env) const = 0; +}; + +template +requires(std::is_base_of_v) class NapiCallback +{ + public: + explicit NapiCallback(napi_env e, std::string_view name, napi_value jsCallback) : env(e) + { + napi_value workName; + napi_create_string_utf8(env, name.data(), NAPI_AUTO_LENGTH, &workName); + napi_create_threadsafe_function(env, nullptr, nullptr, workName, 0, 1, nullptr, nullptr, nullptr, JsInvoker, &tsFn); + napi_create_reference(env, jsCallback, 1, &callbackRef); + } + + ~NapiCallback() + { + napi_delete_reference(env, callbackRef); + } + + void Invoke(const TArg *arg) const + { + const auto argContext = new CallbackArg(); + argContext->callbackRef = callbackRef; + argContext->arg = arg; + napi_acquire_threadsafe_function(tsFn); + napi_call_threadsafe_function(tsFn, argContext, napi_tsfn_nonblocking); + napi_release_threadsafe_function(tsFn, napi_tsfn_release); + } + + private: + struct CallbackArg + { + napi_ref callbackRef = nullptr; + const TArg *arg = nullptr; + }; + + static void JsInvoker(napi_env env, napi_value js_callBack, void *context, void *data) + { + (void) context; + auto *const argContext = reinterpret_cast(data); + napi_get_reference_value(env, argContext->callbackRef, &js_callBack); + const auto arg = argContext->arg->ToNapiValue(env); + napi_call_function(env, nullptr, js_callBack, 1, &arg, nullptr); + delete argContext; + } + + private: + napi_env env = nullptr; + napi_ref callbackRef = nullptr; + napi_threadsafe_function tsFn = nullptr; +}; diff --git a/entry/src/main/cpp/include/NapiCallbackStorage.hpp b/entry/src/main/cpp/include/NapiCallbackStorage.hpp new file mode 100644 index 0000000..5c212ef --- /dev/null +++ b/entry/src/main/cpp/include/NapiCallbackStorage.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "Logging.hpp" +#include "NapiCallback.hpp" + +#include +#include +#include + +template +struct NapiCallbackStorage +{ + public: + explicit NapiCallbackStorage() = default; + + void AddCallback(napi_env env, std::string_view name, napi_value jsCallback) + { + LogD("enter"); + const auto callback = std::make_shared>(env, name, jsCallback); + callbacks.push_back(callback); + } + + void InvokeAll(const TArg *arg) + { + for (const auto &callback : callbacks) + { + callback->Invoke(arg); + } + } + + private: + std::list>> callbacks; +}; diff --git a/entry/src/main/cpp/include/NapiUtils.hpp b/entry/src/main/cpp/include/NapiUtils.hpp index 3643982..5ec66cc 100644 --- a/entry/src/main/cpp/include/NapiUtils.hpp +++ b/entry/src/main/cpp/include/NapiUtils.hpp @@ -32,6 +32,8 @@ struct NapiValueConverter } \ } +DeclareNapiValueConverter(napi_function, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); +DeclareNapiValueConverter(napi_object, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); DeclareNapiValueConverter(napi_number, long, napi_get_value_int64); DeclareNapiValueConverter(napi_boolean, bool, napi_get_value_bool); DeclareNapiValueConverter(napi_string, std::string, @@ -42,15 +44,13 @@ DeclareNapiValueConverter(napi_string, std::string, ret->resize(len, '\0'); napi_get_value_string_utf8(env, value, ret->data(), len + 1, &len); }); -DeclareNapiValueConverter(napi_function, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); -DeclareNapiValueConverter(napi_object, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); template struct NapiArgs { static constexpr auto NTypes = sizeof...(types); using Result = std::tuple::type_t...>; - using OptionalResult = std::tuple::type_t>...>; + using OptionalResult = std::tuple::optional_t...>; using NapiValueArray = std::array; template> diff --git a/entry/src/main/cpp/napi_init.cpp b/entry/src/main/cpp/napi_init.cpp deleted file mode 100644 index 7b37889..0000000 --- a/entry/src/main/cpp/napi_init.cpp +++ /dev/null @@ -1,163 +0,0 @@ -#define LOG_TAG "NativeEntry" - -#include "Logging.hpp" -#include "NapiUtils.hpp" -#include "filemanagement/clouddiskmanager/oh_cloud_disk_manager.h" -#include "magic_enum.hpp" -#include "napi/native_api.h" - -#include -// #include - -static bool canIUse(const char *) -{ - return true; -} - -static napi_value DoAdd(napi_env env, napi_callback_info info) -{ - const auto input = NapiArgs::Get(env, info); - if (!input.has_value()) - return nullptr; - - const auto [alias, filePath] = *input; - LogInfo("input: %{public}s", filePath.c_str()); - - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { - CloudDisk_SyncFolder folder; - folder.displayNameInfo.customAlias = (char *) alias.data(); - folder.displayNameInfo.customAliasLength = alias.length(); - folder.displayNameInfo.displayNameResId = 0; - folder.state = CloudDisk_SyncFolderState::ACTIVE; - folder.path.value = strdup(filePath.c_str()); - folder.path.length = filePath.length(); - const auto ret = OH_CloudDisk_RegisterSyncFolder(&folder); - - LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); - - napi_value retValue; - napi_create_int32(env, ret, &retValue); - return retValue; - } - - return 0; -} - -static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeDatas[], size_t bufferLength) -{ - LogInfo("Path: %{public}s", syncFolderPath.value); - LogInfo("Length: %{public}d", bufferLength); - for (int i = 0; i < bufferLength; i++) - { - const CloudDisk_ChangeData &data = changeDatas[i]; - LogInfo(" #%{public}d, Seq: %{public}d", i, data.updateSequenceNumber); - LogInfo(" Id: %{public}s", data.fileId.value); - LogInfo(" ParentId: %{public}s", data.parentFileId.value); - LogInfo(" Relative: %{public}s", data.relativePathInfo.value); - LogInfo(" OP: %{public}s", magic_enum::enum_name(data.operationType).data()); - LogInfo(" Size: %{public}llu", data.size); - LogInfo(" MTime: %{public}llu", data.mtime); - } -} - -static napi_value DoConnect(napi_env env, napi_callback_info info) -{ - const auto input = NapiArgs::Get(env, info); - if (!input.has_value()) - return nullptr; - - const auto [filePath] = *input; - LogInfo("input: %{public}s", filePath.c_str()); - - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { - CloudDisk_SyncFolderPath path; - path.value = strdup(filePath.c_str()); - path.length = filePath.length(); - const auto ret = OH_CloudDisk_ActiveSyncFolder(path); - LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); - - OH_CloudDisk_RegisterSyncFolderChanges(path, OnChangedCallback); - - napi_value retValue; - napi_create_int32(env, ret, &retValue); - return retValue; - } - - return 0; -} - -static napi_value DoDisconnect(napi_env env, napi_callback_info info) -{ - const auto input = NapiArgs::Get(env, info); - if (!input.has_value()) - return nullptr; - - const auto [filePath] = *input; - LogInfo("input: %{public}s", filePath.c_str()); - - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { - CloudDisk_SyncFolderPath path; - path.value = strdup(filePath.c_str()); - path.length = filePath.length(); - OH_CloudDisk_UnregisterSyncFolderChanges(path); - const auto ret = OH_CloudDisk_DeactiveSyncFolder(path); - LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); - - napi_value retValue; - napi_create_int32(env, ret, &retValue); - return retValue; - } - - return 0; -} - -static napi_value DoRemove(napi_env env, napi_callback_info info) -{ - const auto input = NapiArgs::Get(env, info); - if (!input.has_value()) - return nullptr; - - const auto [filePath] = *input; - - CloudDisk_SyncFolderPath path; - path.value = strdup(filePath.c_str()); - path.length = filePath.length(); - const auto ret = OH_CloudDisk_UnregisterSyncFolder(path); - LogInfo("returned: %{public}s", magic_enum::enum_name(ret).data()); - - napi_value retValue; - napi_create_int32(env, ret, &retValue); - return retValue; -} - -EXTERN_C_START -static napi_value Init(napi_env env, napi_value exports) -{ - napi_property_descriptor desc[] = { - { "add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr }, - }; - napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); - return exports; -} -EXTERN_C_END - -static napi_module demoModule = { - .nm_version = 1, - .nm_flags = 0, - .nm_filename = nullptr, - .nm_register_func = Init, - .nm_modname = "cloudsync", - .nm_priv = ((void *) 0), - .reserved = { 0 }, -}; - -extern "C" __attribute__((constructor)) void RegisterCloudSyncModule(void) -{ - napi_module_register(&demoModule); -} diff --git a/entry/src/main/cpp/src/napi_init.cpp b/entry/src/main/cpp/src/napi_init.cpp new file mode 100644 index 0000000..6dc8e83 --- /dev/null +++ b/entry/src/main/cpp/src/napi_init.cpp @@ -0,0 +1,399 @@ +#define LOG_TAG "Native" +#include "Logging.hpp" +#include "NapiCallback.hpp" +#include "NapiUtils.hpp" +#include "filemanagement/clouddiskmanager/oh_cloud_disk_manager.h" +#include "include/NapiCallbackStorage.hpp" +#include "magic_enum.hpp" +#include "napi/native_api.h" + +using namespace std::chrono_literals; + +// #include + +static bool canIUse(const char *) +{ + return true; +} + +struct FileChangeData : public NapiObject +{ + FileChangeData(std::string_view fileId, std::string_view parentId, std::string_view path, CloudDisk_OperationType opType, uint64_t sz, uint64_t mtimeVal) + : fileId(fileId), parentFileId(parentId), relativePath(path), operationType(opType), size(sz), mtime(mtimeVal) + { + } + + std::string_view fileId; + std::string_view parentFileId; + std::string_view relativePath; + CloudDisk_OperationType operationType; + uint64_t size; + uint64_t mtime; + + napi_value ToNapiValue(napi_env env) const override + { + napi_value obj; + napi_create_object(env, &obj); + + napi_value napiFileId; + napi_create_string_utf8(env, fileId.data(), fileId.length(), &napiFileId); + napi_set_named_property(env, obj, "fileId", napiFileId); + + napi_value napiParentFileId; + napi_create_string_utf8(env, parentFileId.data(), parentFileId.length(), &napiParentFileId); + napi_set_named_property(env, obj, "parentFileId", napiParentFileId); + + napi_value napiRelativePath; + napi_create_string_utf8(env, relativePath.data(), relativePath.length(), &napiRelativePath); + napi_set_named_property(env, obj, "relativePath", napiRelativePath); + + napi_value napiOperationType; + napi_create_int32(env, static_cast(operationType), &napiOperationType); + napi_set_named_property(env, obj, "operationType", napiOperationType); + + napi_value napiSize; + napi_create_int64(env, static_cast(size), &napiSize); + napi_set_named_property(env, obj, "size", napiSize); + + napi_value napiMtime; + napi_create_int64(env, static_cast(mtime), &napiMtime); + napi_set_named_property(env, obj, "mtime", napiMtime); + + return obj; + } +}; + +enum class SyncState +{ + IDLE = CloudDisk_SyncState::IDLE, + SYNCING = CloudDisk_SyncState::SYNCING, + SUCCEEDED = CloudDisk_SyncState::SYNC_SUCCEEDED, + FAILED = CloudDisk_SyncState::SYNC_FAILED, + CANCELED = CloudDisk_SyncState::SYNC_CANCELED, + CONFLICTED = CloudDisk_SyncState::SYNC_CONFLICTED, +}; + +struct SyncFileState +{ + std::string name; + size_t size; + SyncState syncState; + + static std::optional FromNapiValue(napi_env env, napi_value value) + { + if (value == nullptr) + return std::nullopt; + + // check if is object + { + napi_valuetype valueType = napi_undefined; + napi_typeof(env, value, &valueType); + if (valueType != napi_object) + return std::nullopt; + } + + SyncFileState entry; + for (const std::string_view prop : { "name", "size", "syncState" }) + { + napi_value napiProp; + const auto ret = napi_get_named_property(env, value, prop.data(), &napiProp); + if (ret != napi_ok) + { + LogE("Failed to get property %{public}s", prop.data()); + return std::nullopt; + } + + if (prop == "name") + { + const auto nameOpt = NapiValueConverter::Get(env, napiProp); + if (!nameOpt.has_value()) + { + LogE("Property %{public}s is not a string", prop.data()); + return std::nullopt; + } + entry.name = *nameOpt; + } + else if (prop == "size") + { + const auto sizeOpt = NapiValueConverter::Get(env, napiProp); + if (!sizeOpt.has_value()) + { + LogE("Property %{public}s is not a number", prop.data()); + return std::nullopt; + } + entry.size = static_cast(*sizeOpt); + } + else if (prop == "syncState") + { + const auto stateOpt = NapiValueConverter::Get(env, napiProp); + if (!stateOpt.has_value()) + { + LogE("Property %{public}s is not a number", prop.data()); + return std::nullopt; + } + entry.syncState = static_cast(*stateOpt); + } + } + + return entry; + } + + std::string ToString() const + { + return "SyncFileState{ name: " + name + ", size: " + std::to_string(size) + ", syncState: " + magic_enum::enum_name(syncState).data() + " }"; + } +}; + +static NapiCallbackStorage g_callbackContexts; + +static napi_value DoAdd(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [alias, filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolder folder; + folder.displayNameInfo.customAlias = (char *) alias.data(); + folder.displayNameInfo.customAliasLength = alias.length(); + folder.displayNameInfo.displayNameResId = 0; + folder.state = CloudDisk_SyncFolderState::ACTIVE; + folder.path.value = strdup(filePath.c_str()); + folder.path.length = filePath.length(); + const auto ret = OH_CloudDisk_RegisterSyncFolder(&folder); + + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeData[], size_t bufferLength) +{ + LogI("received %{public}zu events in %{public}s", bufferLength, syncFolderPath.value); + for (int i = 0; i < bufferLength; i++) + { + const CloudDisk_ChangeData &data = changeData[i]; + const auto change = new FileChangeData{ + std::string_view{ data.fileId.value, data.fileId.length }, + std::string_view{ data.parentFileId.value, data.parentFileId.length }, + std::string_view{ data.relativePathInfo.value, data.relativePathInfo.length }, + data.operationType, + data.size, + data.mtime, + }; + g_callbackContexts.InvokeAll(change); + } +} + +static napi_value DoConnect(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_ActiveSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + OH_CloudDisk_RegisterSyncFolderChanges(path, OnChangedCallback); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoDisconnect(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + OH_CloudDisk_UnregisterSyncFolderChanges(path); + const auto ret = OH_CloudDisk_DeactiveSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoRemove(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_UnregisterSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; +} + +static napi_value DoRegisterFileChangeMonitor(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [callback] = *input; + g_callbackContexts.AddCallback(env, "FileChangeMonitor", callback); + return nullptr; +} + +static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [dirPath, entries] = *input; + LogI("input: %{public}s", dirPath.c_str()); + + std::list dirEntries; + { + bool isArray = false; + napi_is_array(env, entries, &isArray); + if (!isArray) + { + LogE("entries is not an array"); + return nullptr; + } + + uint32_t length = 0; + napi_get_array_length(env, entries, &length); + for (uint32_t i = 0; i < length; i++) + { + napi_value element; + napi_get_element(env, entries, i, &element); + const auto entryOpt = SyncFileState::FromNapiValue(env, element); + if (!entryOpt.has_value()) + { + LogE("Failed to parse entry at index %{public}u", i); + return nullptr; + } + + LogI("Parsed entry at index %{public}u: %{public}s", i, entryOpt->ToString().c_str()); + dirEntries.push_back(*entryOpt); + } + } + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(dirPath.c_str()); + path.length = dirPath.length(); + + std::vector fileSyncStates; + for (const auto &entry : dirEntries) + { + CloudDisk_FileSyncState state; + state.filePathInfo.value = strdup((dirPath + '/' + entry.name).c_str()); + state.filePathInfo.length = strlen(state.filePathInfo.value); + state.syncState = static_cast(entry.syncState); + fileSyncStates.push_back(state); + } + + CloudDisk_FailedList *failedLists = nullptr; + size_t failedCount = 0; + const auto ret = OH_CloudDisk_SetFileSyncStates(path, fileSyncStates.data(), fileSyncStates.size(), &failedLists, &failedCount); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + for (size_t i = 0; i < failedCount; i++) + { + const CloudDisk_FailedList &failed = failedLists[i]; + std::string failedPath(failed.pathInfo.value, failed.pathInfo.length); + LogW("Failed to set state for file %{public}s, reason: %{public}s", failedPath.c_str(), + magic_enum::enum_name(failed.errorReason).data()); + } + + napi_value retValue; + napi_create_int32(env, 0, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) +{ + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath, alias] = *input; + LogI("input: %{public}s, %{public}s", filePath.c_str(), alias.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) + { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + + const auto ret = OH_CloudDisk_UpdateCustomAlias(path, alias.c_str(), alias.length()); + + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value Init(napi_env env, napi_value exports) +{ + const napi_property_descriptor desc[] = { + { "add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "registerFileChangeMonitor", nullptr, DoRegisterFileChangeMonitor, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "setLocalFileState", nullptr, DoSetLocalFileState, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "updateCustomAlias", nullptr, DoUpdateCustomAlias, nullptr, nullptr, nullptr, napi_default, nullptr }, + }; + + // add methods to export + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + + return exports; +} + +NAPI_MODULE_X(cloudsync, Init, nullptr, 0) diff --git a/entry/src/main/cpp/types/libcloudsync/Index.d.ts b/entry/src/main/cpp/types/libcloudsync/Index.d.ts index 9ed2d77..80e1256 100644 --- a/entry/src/main/cpp/types/libcloudsync/Index.d.ts +++ b/entry/src/main/cpp/types/libcloudsync/Index.d.ts @@ -1,7 +1,47 @@ -export const add: (alias: string, path: string) => number; +export enum FileChangeOperationType { + CREATE = 0, + DELETE = 1, + MOVE_FROM = 2, + MOVE_TO = 3, + CLOSE_WRITE = 4, + SYNC_FOLDER_INVALID = 5, +} -export const remove: (path: string) => number; +interface FileChangeInfo { + fileId: string; + parentFileId: string; + relativePath: string; + operationType: FileChangeOperationType; + size: number; + mtime: number; +} -export const connect: (path: string) => number; +export enum SyncState { + IDLE = 0, + SYNCING = 1, + SUCCEEDED = 2, + FAILED = 3, + CANCELED = 4, + CONFLICTED = 5, +} -export const disconnect: (path: string) => number; +interface SyncFileState { + name: string; + size: number; + syncState: SyncState; +} + + +export function add(alias: string, path: string): number; + +export function remove(path: string): number; + +export function connect(path: string): number; + +export function disconnect(path: string): number; + +export function registerFileChangeMonitor(callback: (fileChangeInfo: FileChangeInfo) => void): void; + +export function setLocalFileState(path: string, entries: SyncFileState[]): number; + +export function updateCustomAlias(path: string, newAlias: string): number; diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 071db29..19df3c6 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -43,6 +43,7 @@ export default class EntryAbility extends UIAbility { await windowStage.loadContent(page); if (isPc) { PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_PATH, ''); + PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_ALIAS, ''); } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); } @@ -69,7 +70,7 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Ability has back to background'); } - onMemoryLevel(level: AbilityConstant.MemoryLevel): void { + async onMemoryLevel(level: AbilityConstant.MemoryLevel) { Logger.Info(TAG, `onMemoryLevel: level = ${AbilityConstant.MemoryLevel[level]}`); } }; diff --git a/entry/src/main/ets/pages/FolderPage.ets b/entry/src/main/ets/pages/FolderPage.ets index 13b5080..29b943e 100644 --- a/entry/src/main/ets/pages/FolderPage.ets +++ b/entry/src/main/ets/pages/FolderPage.ets @@ -29,10 +29,14 @@ export struct FolderPage { let props: PreviewPageProps = { entry: entry, fullPath: this.currentPath.getChild(entry.name) }; pathInfo = new NavPathInfo(PREVIEW_PAGE_NAME, props); } else { - promptAction.showToast({ - message: `No preview available for ${entry.name}`, - duration: 2000, - }); + try { + this.getUIContext().getPromptAction().showToast({ + message: `No preview available for ${entry.name}`, + duration: 2000, + }); + } catch (error) { + Logger.Error(TAG, `Failed to show toast: ${JSON.stringify(error)}`); + } return; } diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 71e96d1..102ab5d 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -1,8 +1,8 @@ -import { add, connect, disconnect, remove } from 'libcloudsync.so'; -import { Logger } from 'nextcloud'; +import { DirectoryEntry, ListFiles, Logger } from 'nextcloud'; import { common } from '@kit.AbilityKit'; import { fileUri, picker } from '@kit.CoreFileKit'; import { Constants } from '../utils/Common'; +import * as CloudSyncApi from '../utils/CloudSyncApi'; const TAG = 'PcAbility' @@ -10,58 +10,135 @@ const TAG = 'PcAbility' @Component struct PCAbility { @StorageLink(Constants.PC_CLOUD_DISK_PATH) private path: string = ''; + @StorageLink(Constants.PC_CLOUD_DISK_ALIAS) @Watch('onAliasChanged') private alias: string = 'Moody 的 Nextcloud'; + @State private opHistory: CloudSyncApi.FileChangeInfo[] = []; + @State private files: DirectoryEntry[] = []; - aboutToAppear(): void { + onAliasChanged(): void { + CloudSyncApi.updateCustomAlias(this.path, this.alias); + } + + async aboutToAppear(): Promise { Logger.Info(TAG, `pcAbility aboutToAppear`); + if (this.path) { + CloudSyncApi.connect(this.path); + } + CloudSyncApi.registerFileChangeMonitor((info: CloudSyncApi.FileChangeInfo) => { + Logger.Info(TAG, `${CloudSyncApi.FileChangeOperationType[info.operationType]} ::: '${info.relativePath}'`); + this.opHistory = [info, ...this.opHistory]; + }); + + this.files = await ListFiles('/'); + for (let f of this.files) { + Logger.Info(TAG, `${JSON.stringify(f)}`); + } } aboutToDisappear(): void { - disconnect(this.path); + CloudSyncApi.disconnect(this.path); } build() { - Column({ space: 10 }) { - Text(`Root Path: ${this.path}`) - - Button('select path') - .onClick(async () => { - let context = this.getUIContext().getHostContext() as common.UIAbilityContext; - let documentPicker = new picker.DocumentViewPicker(context); - let items = await documentPicker.select({ - defaultFilePathUri: 'file://docs/storage/Users/currentUser/', - maxSelectNumber: 1, - selectMode: picker.DocumentSelectMode.FOLDER - }); - if (items.length !== 1) { - Logger.Error(TAG, `???`); - return; + Row({ space: 16 }) { + Column({ space: 16 }) { + Text(`Root Path: ${this.path}`) + + TextInput({ text: this.alias!! }) + + Button('select path') + .onClick(async () => { + let context = this.getUIContext().getHostContext() as common.UIAbilityContext; + let documentPicker = new picker.DocumentViewPicker(context); + let items = await documentPicker.select({ + defaultFilePathUri: 'file://docs/storage/Users/currentUser/', + maxSelectNumber: 1, + selectMode: picker.DocumentSelectMode.FOLDER + }); + if (items.length !== 1) { + Logger.Error(TAG, `???`); + return; + } + let path = new fileUri.FileUri(items[0]).path; + this.path = path; + + }) + + Button('create root dir') + .onClick(async () => { + let addResult: number = CloudSyncApi.add("Moody 的 Nextcloud", this.path); + Logger.Info(TAG, `Add: ${addResult}`) + }) + + Button('connect') + .onClick(async () => { + CloudSyncApi.connect(this.path); + }) + + Button('disconnect') + .onClick(() => { + CloudSyncApi.disconnect(this.path); + }) + + Button('remove root dir') + .onClick(() => { + CloudSyncApi.remove(this.path); + }) + } + .padding(8) + .width(300) + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + + Column({ space: 16 }) { + Column({ space: 8 }) { + List() { + ForEach(this.files, (item: DirectoryEntry, index: number) => { + ListItem() { + Text(`${index}: ${item.name} --- ${item.isDirectory ? 'dir' : item.mime}`) + } + .clip(true) + }) + } + .width('100%') + .height(150) + + Button('apply') + .onClick(async () => { + let state: CloudSyncApi.SyncFileState = { + name: 'testfile', + size: 12345, + syncState: CloudSyncApi.SyncState.SYNCING, + }; + CloudSyncApi.setLocalFileState(this.path, [state]); + }) + } + .padding(8) + .width('100%') + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + + Column({ space: 8 }) { + Text('Operation History:') + List() { + ForEach(this.opHistory, (item: CloudSyncApi.FileChangeInfo, index) => { + ListItem() { + Row() { + Text(`${index + 1}. `) + Text(`FileId: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}`) + } + } + }) } - let path = new fileUri.FileUri(items[0]).path; - this.path = path; - - }) - - Button('create root dir') - .onClick(async () => { - let addResult = add("Moody 的 Nextcloud", this.path); - Logger.Info(TAG, `Add: ${addResult}`) - }) - - Button('connect') - .onClick(async () => { - connect(this.path); - }) - - Button('disconnect') - .onClick(() => { - disconnect(this.path); - }) - - Button('remove root dir') - .onClick(() => { - remove(this.path); - }) + .width('100%') + .height(350) + } + .padding(8) + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + } + .width(600) + .padding(8) + .border({ color: Color.Blue, width: 2 }) } + .width('100%') + .height('100%') .margin(16) } } diff --git a/entry/src/main/ets/utils/CloudSyncApi.ets b/entry/src/main/ets/utils/CloudSyncApi.ets new file mode 100644 index 0000000..43422a7 --- /dev/null +++ b/entry/src/main/ets/utils/CloudSyncApi.ets @@ -0,0 +1,36 @@ +export { + add, remove, connect, disconnect, registerFileChangeMonitor, updateCustomAlias, setLocalFileState +} from 'libcloudsync.so'; + +export enum FileChangeOperationType { + CREATE = 0, + DELETE = 1, + MOVE_FROM = 2, + MOVE_TO = 3, + CLOSE_WRITE = 4, + SYNC_FOLDER_INVALID = 5, +} + +export interface FileChangeInfo { + fileId: string; + parentFileId: string; + relativePath: string; + operationType: FileChangeOperationType; + size: number; + mtime: number; +} + +export enum SyncState { + IDLE = 0, + SYNCING = 1, + SUCCEEDED = 2, + FAILED = 3, + CANCELED = 4, + CONFLICTED = 5, +} + +export interface SyncFileState { + name: string; + size: number; + syncState: SyncState; +} diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index d909e1e..e42bd5c 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -8,4 +8,6 @@ export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; export const PC_CLOUD_DISK_PATH = 'PC.CloudDisk.Path'; + + export const PC_CLOUD_DISK_ALIAS = 'PC.CloudDisk.Alias'; } From 9f550e57dc7010e41f8a0246c28dd6c470b39322 Mon Sep 17 00:00:00 2001 From: Moody Date: Thu, 8 Jan 2026 22:24:53 +0800 Subject: [PATCH 04/16] temp state --- .gitignore | 2 + AppScope/resources/base/media/tray_icon.png | Bin 0 -> 540 bytes entry/patch.json | 2 +- entry/src/main/cpp/CMakeLists.txt | 1 + entry/src/main/cpp/src/napi_init.cpp | 150 ++++++------------ .../main/cpp/types/libcloudsync/Index.d.ts | 41 +++-- .../main/ets/entryability/EntryAbility.ets | 63 +++++++- .../main/ets/models/ImageListDataSource.ets | 6 +- entry/src/main/ets/models/PhotoItem.ets | 2 +- entry/src/main/ets/pages/LoginPage.ets | 11 +- entry/src/main/ets/pages/PcMainPage.ets | 131 +++++++++++++-- .../main/ets/statusbar/StatusBarAbility.ets | 32 ++++ .../src/main/ets/statusbar/StatusBarPage.ets | 74 +++++++++ entry/src/main/ets/utils/CloudSyncApi.ets | 1 - entry/src/main/ets/utils/DownloadQueue.ets | 70 ++++++++ entry/src/main/module.json5 | 17 +- .../main/resources/base/element/float.json | 8 + .../main/resources/base/element/string.json | 2 +- .../resources/base/profile/main_pages.json | 5 +- .../main/resources/zh_CN/element/string.json | 2 +- nextcloud/index.ets | 15 -- nextcloud/oh-package.json5 | 6 +- nextcloud/src/main/ets/Account/Account.ets | 21 +++ .../src/main/ets/{ => Account}/Login.ets | 6 +- nextcloud/src/main/ets/Common.ets | 2 +- nextcloud/src/main/ets/Index.ets | 23 +++ nextcloud/src/main/ets/Init.ets | 39 +++++ nextcloud/src/main/ets/Jobs/BaseJob.ets | 15 ++ nextcloud/src/main/ets/Jobs/Index.ets | 1 + nextcloud/src/main/ets/Jobs/MkColJob.ets | 7 + .../src/main/ets/SyncEngine/SyncFolder.ets | 24 +++ .../main/ets/SyncEngine/SyncFolderManager.ets | 28 ++++ nextcloud/src/main/ets/TransferManager.ets | 54 +++++++ .../main/ets/{ => Utils}/BasicDatasource.ets | 11 ++ .../main/ets/{ => Utils}/FilesDataSource.ets | 4 +- nextcloud/src/main/ets/{ => Utils}/Logger.ts | 0 .../src/main/ets/Utils/StorageAdapter.ets | 92 +++++++++++ .../ets/webdav/DirEntryParserStateMachine.ts | 2 +- nextcloud/src/main/ets/webdav/Files.ets | 2 +- nextcloud/src/main/ets/webdav/Uploader.ets | 38 ++++- watch_logs.cache.json | 1 + 41 files changed, 823 insertions(+), 188 deletions(-) create mode 100644 AppScope/resources/base/media/tray_icon.png create mode 100644 entry/src/main/ets/statusbar/StatusBarAbility.ets create mode 100644 entry/src/main/ets/statusbar/StatusBarPage.ets create mode 100644 entry/src/main/ets/utils/DownloadQueue.ets create mode 100644 entry/src/main/resources/base/element/float.json delete mode 100644 nextcloud/index.ets create mode 100644 nextcloud/src/main/ets/Account/Account.ets rename nextcloud/src/main/ets/{ => Account}/Login.ets (95%) create mode 100644 nextcloud/src/main/ets/Index.ets create mode 100644 nextcloud/src/main/ets/Init.ets create mode 100644 nextcloud/src/main/ets/Jobs/BaseJob.ets create mode 100644 nextcloud/src/main/ets/Jobs/Index.ets create mode 100644 nextcloud/src/main/ets/Jobs/MkColJob.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/SyncFolder.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets create mode 100644 nextcloud/src/main/ets/TransferManager.ets rename nextcloud/src/main/ets/{ => Utils}/BasicDatasource.ets (86%) rename nextcloud/src/main/ets/{ => Utils}/FilesDataSource.ets (86%) rename nextcloud/src/main/ets/{ => Utils}/Logger.ts (100%) create mode 100644 nextcloud/src/main/ets/Utils/StorageAdapter.ets create mode 100644 watch_logs.cache.json diff --git a/.gitignore b/.gitignore index 52623fd..5e7624c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ node_modules /.clang-tidy **/.test /.appanalyzer + +.bitfun/ diff --git a/AppScope/resources/base/media/tray_icon.png b/AppScope/resources/base/media/tray_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..52ff8db7862a6563ab38d3e468bc45611470c6ff GIT binary patch literal 540 zcmV+%0^|LOP)c4o)JuE#}Hxx2-GS3KQ2loB#_h~PE|80tFh=o zh?u1Q%&)_c8pQ$7ZgA^(TABop{FU;9lE>MFYMjCFlOXSh9@A@8hHYcd=er^<{SW?M@_mP_!#;46ML`3tG-Sm zW-F^Z$9&2;lrNp`AA_0UE?z$kvG01AeuHe`gg~?8VXMKj51ZtV$^e8re0+Tw;qcVM zJ^xL-s~xB0(HSt9{kF@Sl|s!x{&j-M0gI)rB9S?pskH)w;UN8?HWqVEO&lP9ROWNe zp+D5dU^vLsT7k%%&C*tp$pH&-DI)Dtq&=b=o7`*pS)S-2U#!FeFdj7t-=)b{(XcVS z4y_jr;Os)Ndvbo;@*4O5v6FzOQQwo*4_XgO*%C+tt_FD*NJ~=j3`!+XYMsW!<)PF% eN+n3eGk*Y0kKjNNUqNsH0000 +#include -static bool canIUse(const char *) -{ - return true; -} - -struct FileChangeData : public NapiObject -{ - FileChangeData(std::string_view fileId, std::string_view parentId, std::string_view path, CloudDisk_OperationType opType, uint64_t sz, uint64_t mtimeVal) - : fileId(fileId), parentFileId(parentId), relativePath(path), operationType(opType), size(sz), mtime(mtimeVal) - { +struct FileChangeData : public NapiObject { + FileChangeData(std::string_view fileId, std::string_view parentId, std::string_view path, + CloudDisk_OperationType opType, uint64_t sz, uint64_t mtimeVal) + : fileId(fileId), parentFileId(parentId), relativePath(path), operationType(opType), size(sz), mtime(mtimeVal) { } std::string_view fileId; @@ -30,8 +24,7 @@ struct FileChangeData : public NapiObject uint64_t size; uint64_t mtime; - napi_value ToNapiValue(napi_env env) const override - { + napi_value ToNapiValue(napi_env env) const override { napi_value obj; napi_create_object(env, &obj); @@ -63,8 +56,7 @@ struct FileChangeData : public NapiObject } }; -enum class SyncState -{ +enum class SyncState { IDLE = CloudDisk_SyncState::IDLE, SYNCING = CloudDisk_SyncState::SYNCING, SUCCEEDED = CloudDisk_SyncState::SYNC_SUCCEEDED, @@ -73,14 +65,11 @@ enum class SyncState CONFLICTED = CloudDisk_SyncState::SYNC_CONFLICTED, }; -struct SyncFileState -{ +struct SyncFileState { std::string name; - size_t size; SyncState syncState; - static std::optional FromNapiValue(napi_env env, napi_value value) - { + static std::optional FromNapiValue(napi_env env, napi_value value) { if (value == nullptr) return std::nullopt; @@ -93,41 +82,24 @@ struct SyncFileState } SyncFileState entry; - for (const std::string_view prop : { "name", "size", "syncState" }) - { + for (const std::string_view prop : {"name", "size", "syncState"}) { napi_value napiProp; const auto ret = napi_get_named_property(env, value, prop.data(), &napiProp); - if (ret != napi_ok) - { + if (ret != napi_ok) { LogE("Failed to get property %{public}s", prop.data()); return std::nullopt; } - if (prop == "name") - { + if (prop == "name") { const auto nameOpt = NapiValueConverter::Get(env, napiProp); - if (!nameOpt.has_value()) - { + if (!nameOpt.has_value()) { LogE("Property %{public}s is not a string", prop.data()); return std::nullopt; } entry.name = *nameOpt; - } - else if (prop == "size") - { - const auto sizeOpt = NapiValueConverter::Get(env, napiProp); - if (!sizeOpt.has_value()) - { - LogE("Property %{public}s is not a number", prop.data()); - return std::nullopt; - } - entry.size = static_cast(*sizeOpt); - } - else if (prop == "syncState") - { + } else if (prop == "syncState") { const auto stateOpt = NapiValueConverter::Get(env, napiProp); - if (!stateOpt.has_value()) - { + if (!stateOpt.has_value()) { LogE("Property %{public}s is not a number", prop.data()); return std::nullopt; } @@ -138,16 +110,14 @@ struct SyncFileState return entry; } - std::string ToString() const - { - return "SyncFileState{ name: " + name + ", size: " + std::to_string(size) + ", syncState: " + magic_enum::enum_name(syncState).data() + " }"; + std::string ToString() const { + return "SyncFileState{ name: " + name + ", syncState: " + magic_enum::enum_name(syncState).data() + " }"; } }; static NapiCallbackStorage g_callbackContexts; -static napi_value DoAdd(napi_env env, napi_callback_info info) -{ +static napi_value DoAdd(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -155,10 +125,9 @@ static napi_value DoAdd(napi_env env, napi_callback_info info) const auto [alias, filePath] = *input; LogI("input: %{public}s", filePath.c_str()); - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { CloudDisk_SyncFolder folder; - folder.displayNameInfo.customAlias = (char *) alias.data(); + folder.displayNameInfo.customAlias = (char *)alias.data(); folder.displayNameInfo.customAliasLength = alias.length(); folder.displayNameInfo.displayNameResId = 0; folder.state = CloudDisk_SyncFolderState::ACTIVE; @@ -176,16 +145,15 @@ static napi_value DoAdd(napi_env env, napi_callback_info info) return 0; } -static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeData[], size_t bufferLength) -{ +static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeData[], + size_t bufferLength) { LogI("received %{public}zu events in %{public}s", bufferLength, syncFolderPath.value); - for (int i = 0; i < bufferLength; i++) - { + for (int i = 0; i < bufferLength; i++) { const CloudDisk_ChangeData &data = changeData[i]; const auto change = new FileChangeData{ - std::string_view{ data.fileId.value, data.fileId.length }, - std::string_view{ data.parentFileId.value, data.parentFileId.length }, - std::string_view{ data.relativePathInfo.value, data.relativePathInfo.length }, + std::string_view{data.fileId.value, data.fileId.length}, + std::string_view{data.parentFileId.value, data.parentFileId.length}, + std::string_view{data.relativePathInfo.value, data.relativePathInfo.length}, data.operationType, data.size, data.mtime, @@ -194,8 +162,7 @@ static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, con } } -static napi_value DoConnect(napi_env env, napi_callback_info info) -{ +static napi_value DoConnect(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -203,8 +170,7 @@ static napi_value DoConnect(napi_env env, napi_callback_info info) const auto [filePath] = *input; LogI("input: %{public}s", filePath.c_str()); - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { CloudDisk_SyncFolderPath path; path.value = strdup(filePath.c_str()); path.length = filePath.length(); @@ -221,8 +187,7 @@ static napi_value DoConnect(napi_env env, napi_callback_info info) return 0; } -static napi_value DoDisconnect(napi_env env, napi_callback_info info) -{ +static napi_value DoDisconnect(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -230,8 +195,7 @@ static napi_value DoDisconnect(napi_env env, napi_callback_info info) const auto [filePath] = *input; LogI("input: %{public}s", filePath.c_str()); - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { CloudDisk_SyncFolderPath path; path.value = strdup(filePath.c_str()); path.length = filePath.length(); @@ -247,8 +211,7 @@ static napi_value DoDisconnect(napi_env env, napi_callback_info info) return 0; } -static napi_value DoRemove(napi_env env, napi_callback_info info) -{ +static napi_value DoRemove(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -266,8 +229,7 @@ static napi_value DoRemove(napi_env env, napi_callback_info info) return retValue; } -static napi_value DoRegisterFileChangeMonitor(napi_env env, napi_callback_info info) -{ +static napi_value DoRegisterFileChangeMonitor(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -277,8 +239,7 @@ static napi_value DoRegisterFileChangeMonitor(napi_env env, napi_callback_info i return nullptr; } -static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) -{ +static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -290,21 +251,18 @@ static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) { bool isArray = false; napi_is_array(env, entries, &isArray); - if (!isArray) - { + if (!isArray) { LogE("entries is not an array"); return nullptr; } uint32_t length = 0; napi_get_array_length(env, entries, &length); - for (uint32_t i = 0; i < length; i++) - { + for (uint32_t i = 0; i < length; i++) { napi_value element; napi_get_element(env, entries, i, &element); const auto entryOpt = SyncFileState::FromNapiValue(env, element); - if (!entryOpt.has_value()) - { + if (!entryOpt.has_value()) { LogE("Failed to parse entry at index %{public}u", i); return nullptr; } @@ -314,15 +272,13 @@ static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) } } - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { CloudDisk_SyncFolderPath path; path.value = strdup(dirPath.c_str()); path.length = dirPath.length(); std::vector fileSyncStates; - for (const auto &entry : dirEntries) - { + for (const auto &entry : dirEntries) { CloudDisk_FileSyncState state; state.filePathInfo.value = strdup((dirPath + '/' + entry.name).c_str()); state.filePathInfo.length = strlen(state.filePathInfo.value); @@ -332,11 +288,11 @@ static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) CloudDisk_FailedList *failedLists = nullptr; size_t failedCount = 0; - const auto ret = OH_CloudDisk_SetFileSyncStates(path, fileSyncStates.data(), fileSyncStates.size(), &failedLists, &failedCount); + const auto ret = OH_CloudDisk_SetFileSyncStates(path, fileSyncStates.data(), fileSyncStates.size(), + &failedLists, &failedCount); LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); - for (size_t i = 0; i < failedCount; i++) - { + for (size_t i = 0; i < failedCount; i++) { const CloudDisk_FailedList &failed = failedLists[i]; std::string failedPath(failed.pathInfo.value, failed.pathInfo.length); LogW("Failed to set state for file %{public}s, reason: %{public}s", failedPath.c_str(), @@ -351,8 +307,7 @@ static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) return 0; } -static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) -{ +static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) { const auto input = NapiArgs::Get(env, info); if (!input.has_value()) return nullptr; @@ -360,8 +315,7 @@ static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) const auto [filePath, alias] = *input; LogI("input: %{public}s, %{public}s", filePath.c_str(), alias.c_str()); - if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) - { + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { CloudDisk_SyncFolderPath path; path.value = strdup(filePath.c_str()); path.length = filePath.length(); @@ -378,16 +332,16 @@ static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) return 0; } -static napi_value Init(napi_env env, napi_value exports) -{ +static napi_value Init(napi_env env, napi_value exports) { const napi_property_descriptor desc[] = { - { "add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "registerFileChangeMonitor", nullptr, DoRegisterFileChangeMonitor, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "setLocalFileState", nullptr, DoSetLocalFileState, nullptr, nullptr, nullptr, napi_default, nullptr }, - { "updateCustomAlias", nullptr, DoUpdateCustomAlias, nullptr, nullptr, nullptr, napi_default, nullptr }, + {"add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"registerFileChangeMonitor", nullptr, DoRegisterFileChangeMonitor, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"setLocalFileState", nullptr, DoSetLocalFileState, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"updateCustomAlias", nullptr, DoUpdateCustomAlias, nullptr, nullptr, nullptr, napi_default, nullptr}, }; // add methods to export diff --git a/entry/src/main/cpp/types/libcloudsync/Index.d.ts b/entry/src/main/cpp/types/libcloudsync/Index.d.ts index 80e1256..4bdb3e7 100644 --- a/entry/src/main/cpp/types/libcloudsync/Index.d.ts +++ b/entry/src/main/cpp/types/libcloudsync/Index.d.ts @@ -1,34 +1,33 @@ export enum FileChangeOperationType { - CREATE = 0, - DELETE = 1, - MOVE_FROM = 2, - MOVE_TO = 3, - CLOSE_WRITE = 4, - SYNC_FOLDER_INVALID = 5, + CREATE = 0, + DELETE = 1, + MOVE_FROM = 2, + MOVE_TO = 3, + CLOSE_WRITE = 4, + SYNC_FOLDER_INVALID = 5, } interface FileChangeInfo { - fileId: string; - parentFileId: string; - relativePath: string; - operationType: FileChangeOperationType; - size: number; - mtime: number; + fileId: string; + parentFileId: string; + relativePath: string; + operationType: FileChangeOperationType; + size: number; + mtime: number; } export enum SyncState { - IDLE = 0, - SYNCING = 1, - SUCCEEDED = 2, - FAILED = 3, - CANCELED = 4, - CONFLICTED = 5, + IDLE = 0, + SYNCING = 1, + SUCCEEDED = 2, + FAILED = 3, + CANCELED = 4, + CONFLICTED = 5, } interface SyncFileState { - name: string; - size: number; - syncState: SyncState; + name: string; + syncState: SyncState; } diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 19df3c6..569d7b8 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,12 +1,12 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { Logger, NCommon } from 'nextcloud'; +import { InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; - import { Notification } from '../utils/NotificationManager'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { deviceInfo } from '@kit.BasicServicesKit'; +import { statusBarManager } from '@kit.DeskTopExtensionKit'; const TAG = 'EntryAbility'; @@ -21,12 +21,22 @@ export default class EntryAbility extends UIAbility { Logger.Error(TAG, `failed to set color mode!`); } NCommon.Initialise(this.context); + await InitialiseNextcloud(this.context); } onDestroy() { Logger.Info(TAG, 'Ability onDestroy'); } + convertToPixelMap(res: Resource) { + // 获取 resourceManager + const resManager = this.context.resourceManager; + + // 获取 DrawableDescriptor 并转换为 PixelMap + const drawableDesc = resManager?.getDrawableDescriptor(res.id) as DrawableDescriptor; + return drawableDesc?.getPixelMap(); // 直接获取 PixelMap + } + async onWindowStageCreate(windowStage: window.WindowStage) { Logger.Info(TAG, 'Main window is created, set main page for this ability'); @@ -44,6 +54,48 @@ export default class EntryAbility extends UIAbility { if (isPc) { PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_PATH, ''); PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_ALIAS, ''); + + // 构建右键菜单项内容 + let subMenus: Array = []; + let subMenuItemAction: statusBarManager.StatusBarMenuAction = { + abilityName: "EntryAbility" + } + let subMenu: statusBarManager.StatusBarSubMenuItem = { + subTitle: "子菜单项", + menuAction: subMenuItemAction + } + subMenus.push(subMenu); + + let statusBarMenuItems: Array = []; + let menuItem: statusBarManager.StatusBarMenuItem = { + title: "一级菜单项", + // 一级menuAction和subMenu两项不可都缺省 + subMenu: subMenus + }; + statusBarMenuItems.push(menuItem); + + let statusBarGroupMenus: Array = []; + statusBarGroupMenus.push(statusBarMenuItems); + + let img = this.convertToPixelMap($r('app.media.tray_icon')); + + try { + statusBarManager.addToStatusBar(this.context, { + icons: { + black: img, + white: img, + }, + quickOperation: { + title: this.context.resourceManager.getStringSync($r('app.string.app_name').id), + height: 400, + abilityName: 'StatusBarAbility', + }, + statusBarGroupMenu: statusBarGroupMenus, + }); + } catch (e) { + Logger.Error(TAG, `failed to add to status bar: ${JSON.stringify(e)}`); + } + } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); } @@ -56,12 +108,15 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Main window created.'); } + async onPrepareToTerminateAsync(): Promise { + await this.context.hideAbility(); + return true; + } onWindowStageDestroy() { Logger.Info(TAG, 'Main window is destroyed, release UI related resources'); } - async onForeground() { Logger.Info(TAG, 'Ability has brought to foreground'); } @@ -73,4 +128,4 @@ export default class EntryAbility extends UIAbility { async onMemoryLevel(level: AbilityConstant.MemoryLevel) { Logger.Info(TAG, `onMemoryLevel: level = ${AbilityConstant.MemoryLevel[level]}`); } -}; +} diff --git a/entry/src/main/ets/models/ImageListDataSource.ets b/entry/src/main/ets/models/ImageListDataSource.ets index ff42566..bcb4f9b 100644 --- a/entry/src/main/ets/models/ImageListDataSource.ets +++ b/entry/src/main/ets/models/ImageListDataSource.ets @@ -8,13 +8,11 @@ export class ImageListDataSource extends BasicDataSource { } public prependAsset(asset: PhotoItem) { - this.entries.unshift(asset); - this.notifyDataAdd(0); + this.prependItem(asset); } public addAsset(asset: PhotoItem) { - this.entries.push(asset); - this.notifyDataAdd(this.entries.length - 1); + this.appendItem(asset); } public addWithoutNotify(asset: PhotoItem) { diff --git a/entry/src/main/ets/models/PhotoItem.ets b/entry/src/main/ets/models/PhotoItem.ets index 7adef15..098428b 100644 --- a/entry/src/main/ets/models/PhotoItem.ets +++ b/entry/src/main/ets/models/PhotoItem.ets @@ -5,7 +5,7 @@ import { FormatTime } from '../utils/Utils'; import { image } from '@kit.ImageKit'; const TAG = 'PhotoItem'; -const DEFAULT_FORMAT = 'yyyy/MM/' +const DEFAULT_FORMAT = 'yyyy/MM/'; export enum UploadStatus { None, diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index 9f25ba6..677e97d 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -1,6 +1,5 @@ -import { LoginProcess, NCommon } from 'nextcloud'; +import { LoginProcess, NCommon, Logger } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; -import { Logger } from 'nextcloud/src/main/ets/Logger'; import { webview } from '@kit.ArkWeb'; const TAG = 'pages/LoginPage' @@ -125,7 +124,13 @@ export struct LoginPage { if (!this.loginStarted) { Text('Type the URL of your Nextcloud instance:') .margin({ left: 24, right: 24, bottom: 8 }) - Flex({ alignItems: ItemAlign.Center, direction: FlexDirection.Row, space: { main: LengthMetrics.vp(8) } }) { + Flex({ + alignItems: ItemAlign.Center, + direction: FlexDirection.Row, + space: { + main: LengthMetrics.vp(8) + } + }) { TextInput({ placeholder: 'https://nextcloud.com', text: NCommon.Preference.hostname }) .onChange((value: string) => { NCommon.Preference.hostname = value; diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 102ab5d..ac923db 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -1,10 +1,38 @@ -import { DirectoryEntry, ListFiles, Logger } from 'nextcloud'; +import { DirectoryEntry, ListFiles, Logger, DownloadFile } from 'nextcloud'; import { common } from '@kit.AbilityKit'; -import { fileUri, picker } from '@kit.CoreFileKit'; +import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; import { Constants } from '../utils/Common'; import * as CloudSyncApi from '../utils/CloudSyncApi'; +import { PermissionManager } from '../utils/PermissionManager'; +import { TransferManager, TransferManagerImpl, TransferTask } from 'nextcloud'; +import { LengthMetrics } from '@kit.ArkUI'; +import { DownloadQueue } from '../utils/DownloadQueue'; const TAG = 'PcAbility' +const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; + +@Component +struct TransferProgressItem { + @ObjectLink transferTask: TransferTask; + + build() { + Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center, space: { main: LengthMetrics.vp(8) } }) { + Text(this.transferTask.filename) + .width(180) + .textOverflow({ overflow: TextOverflow.MARQUEE }) + .textAlign(TextAlign.End) + + Progress({ + value: this.transferTask.transferedSize, + total: this.transferTask.totalSize, + }) + + Text(this.transferTask.progressPercentText) + .width(100) + } + } +} + @Entry @Component @@ -13,6 +41,9 @@ struct PCAbility { @StorageLink(Constants.PC_CLOUD_DISK_ALIAS) @Watch('onAliasChanged') private alias: string = 'Moody 的 Nextcloud'; @State private opHistory: CloudSyncApi.FileChangeInfo[] = []; @State private files: DirectoryEntry[] = []; + @State private transferManager: TransferManagerImpl = TransferManager; + private downloadQueue: DownloadQueue = new DownloadQueue(); + private pendingMoveTo: Map = new Map(); onAliasChanged(): void { CloudSyncApi.updateCustomAlias(this.path, this.alias); @@ -20,18 +51,40 @@ struct PCAbility { async aboutToAppear(): Promise { Logger.Info(TAG, `pcAbility aboutToAppear`); + await PermissionManager.RequestPermission( + this.getUIContext()! + .getHostContext()!, + "ohos.permission.FILE_ACCESS_PERSIST" + ) if (this.path) { CloudSyncApi.connect(this.path); } CloudSyncApi.registerFileChangeMonitor((info: CloudSyncApi.FileChangeInfo) => { + if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_FROM) { + this.pendingMoveTo.set(info.fileId, info.relativePath); + } else if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_TO) { + let moveFrom = this.pendingMoveTo.get(info.fileId); + if (moveFrom === undefined) { + Logger.Error(TAG, `got a MoveTo without a MoveFrom`); + } else { + Logger.Info(TAG, `move: ${moveFrom} -> ${info.relativePath}`); + this.pendingMoveTo.delete(info.fileId); + } + } Logger.Info(TAG, `${CloudSyncApi.FileChangeOperationType[info.operationType]} ::: '${info.relativePath}'`); this.opHistory = [info, ...this.opHistory]; }); - this.files = await ListFiles('/'); - for (let f of this.files) { - Logger.Info(TAG, `${JSON.stringify(f)}`); + try { + await fileShare.activatePermission([{ + uri: fileUri.getUriFromPath(this.path), + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `activate permission done`); + } catch (e) { + Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); } + this.files = await ListFiles('/'); } aboutToDisappear(): void { @@ -50,9 +103,9 @@ struct PCAbility { let context = this.getUIContext().getHostContext() as common.UIAbilityContext; let documentPicker = new picker.DocumentViewPicker(context); let items = await documentPicker.select({ - defaultFilePathUri: 'file://docs/storage/Users/currentUser/', + defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', maxSelectNumber: 1, - selectMode: picker.DocumentSelectMode.FOLDER + selectMode: picker.DocumentSelectMode.FOLDER, }); if (items.length !== 1) { Logger.Error(TAG, `???`); @@ -61,6 +114,16 @@ struct PCAbility { let path = new fileUri.FileUri(items[0]).path; this.path = path; + try { + Logger.Info(TAG, items[0]); + await fileShare.persistPermission([{ + uri: items[0], + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `persist done`); + } catch (e) { + Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); + } }) Button('create root dir') @@ -101,20 +164,54 @@ struct PCAbility { .width('100%') .height(150) - Button('apply') + Button('reload') .onClick(async () => { - let state: CloudSyncApi.SyncFileState = { - name: 'testfile', - size: 12345, - syncState: CloudSyncApi.SyncState.SYNCING, - }; - CloudSyncApi.setLocalFileState(this.path, [state]); + this.files = await ListFiles('/'); + }) + + Button('sync all') + .onClick(async () => { + Logger.Info(TAG, `sync all`); + this.downloadQueue.addRootDir(); + while (this.downloadQueue.hasPendingItem()) { + let next = await this.downloadQueue.getNextDownloadFile(); + if (next === undefined) { + break; + } + Logger.Info(TAG, next.relativePath); + if (next.isDirectory) { + try { + fileIo.mkdirSync(this.path + '/' + next.relativePath); + } catch (e) { + } + } else { + await DownloadFile(next.relativePath, this.path + '/' + next.relativePath); + } + Logger.Info(TAG, next.relativePath); + } + Logger.Info(TAG, `download completed`); }) } .padding(8) .width('100%') .backgroundColor($r('sys.color.glass_material_outline_secondary')) + Column({ space: 8 }) { + Text('download progress') + + List() { + LazyForEach(this.transferManager.tasks, (item: TransferTask) => { + ListItem() { + TransferProgressItem({ + transferTask: item + }) + } + }) + } + .width('100%') + .height(250) + } + Column({ space: 8 }) { Text('Operation History:') List() { @@ -122,13 +219,15 @@ struct PCAbility { ListItem() { Row() { Text(`${index + 1}. `) - Text(`FileId: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}`) + Text( + `: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}` + ) } } }) } .width('100%') - .height(350) + .height(250) } .padding(8) .backgroundColor($r('sys.color.glass_material_outline_secondary')) diff --git a/entry/src/main/ets/statusbar/StatusBarAbility.ets b/entry/src/main/ets/statusbar/StatusBarAbility.ets new file mode 100644 index 0000000..613a2cf --- /dev/null +++ b/entry/src/main/ets/statusbar/StatusBarAbility.ets @@ -0,0 +1,32 @@ +import { UIExtensionContentSession, Want } from '@kit.AbilityKit'; +import { StatusBarViewExtensionAbility } from '@kit.DeskTopExtensionKit'; +import { Logger } from 'nextcloud'; + +const TAG = 'StatusBarView'; + +export default class StatusBarView extends StatusBarViewExtensionAbility { + onCreate() { + Logger.Info(TAG, `onCreate`); + } + + onSessionCreate(want: Want, session: UIExtensionContentSession) { + Logger.Info(TAG, `onSessionCreate, want: ${want.abilityName}`); + session.loadContent('statusbar/StatusBarPage'); + } + + onForeground() { + Logger.Info(TAG, `onForeground`); + } + + onBackground() { + Logger.Info(TAG, `onBackground`); + } + + onSessionDestroy(session: UIExtensionContentSession) { + Logger.Info(TAG, `onSessionDestroy`); + } + + onDestroy() { + Logger.Info(TAG, `onDestroy`); + } +} diff --git a/entry/src/main/ets/statusbar/StatusBarPage.ets b/entry/src/main/ets/statusbar/StatusBarPage.ets new file mode 100644 index 0000000..2ec8590 --- /dev/null +++ b/entry/src/main/ets/statusbar/StatusBarPage.ets @@ -0,0 +1,74 @@ +import { TransferTask, TransferType } from 'nextcloud'; +import { LengthMetrics } from '@kit.ArkUI'; + +@Component +struct TransferProgressItem { + @ObjectLink transferTask: TransferTask; + + build() { + Column({ space: 4 }) { + Text(this.transferTask.filename) + .textOverflow({ overflow: TextOverflow.MARQUEE }) + .textAlign(TextAlign.Start) + .width('100%') + + Progress({ + value: this.transferTask.transferedSize, + total: this.transferTask.totalSize, + }) + .flexGrow(1) + + Text(this.transferTask.progressPercentText) + // .flexBasis() + } + .backgroundColor($r('sys.color.ohos_id_color_hover')) + .padding(8) + .borderRadius(4) + .width('100%') + } +} + +@Entry +@Component +struct StatusBarPage { + @State tasks: TransferTask[] = [ + ]; + + aboutToAppear(): void { + for (let i = 0; i < 80; i++) { + this.tasks.push(new TransferTask(TransferType.DOWNLOAD, `file name ${i}`)); + } + } + + onPageShow(): void { + for (let task of this.tasks) { + let total = Math.random() * 10000; + let transferred = Math.random() * total; + task.updateProgress(transferred, total); + } + + if (false) { + setInterval(() => { + for (let task of this.tasks) { + let transferred = Math.random() * task.totalSize; + task.updateProgress(transferred, task.totalSize); + } + }, 1000); + } + } + + build() { + Scroll() { + Column({ space: 16 }) { + ForEach(this.tasks, (item: TransferTask) => { + TransferProgressItem({ + transferTask: item + }) + }) + } + } + .padding(16) + .width('100%') + .height('100%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/utils/CloudSyncApi.ets b/entry/src/main/ets/utils/CloudSyncApi.ets index 43422a7..91ece89 100644 --- a/entry/src/main/ets/utils/CloudSyncApi.ets +++ b/entry/src/main/ets/utils/CloudSyncApi.ets @@ -31,6 +31,5 @@ export enum SyncState { export interface SyncFileState { name: string; - size: number; syncState: SyncState; } diff --git a/entry/src/main/ets/utils/DownloadQueue.ets b/entry/src/main/ets/utils/DownloadQueue.ets new file mode 100644 index 0000000..6b946ec --- /dev/null +++ b/entry/src/main/ets/utils/DownloadQueue.ets @@ -0,0 +1,70 @@ +import { DirectoryEntry, ListFiles, Logger } from "nextcloud"; + +const DOWNLOAD_DEPTH_FIRST: boolean = true; +const TAG = 'Utils/DownloadQueue'; + +export class DownloadItem { + relativePath: string; + isDirectory: boolean; + + constructor(relativePath: string, isDirectory: boolean) { + this.relativePath = relativePath; + this.isDirectory = isDirectory; + } + + public makeChildItem(filename: string, isDirectory: boolean): DownloadItem { + let relativePath = this.relativePath.endsWith('/') ? this.relativePath : this.relativePath + '/'; + return new DownloadItem(relativePath + filename, isDirectory); + } +} + + +export class DownloadQueue { + queue: DownloadItem[] = []; + + private async shiftNext(): Promise { + let next = this.queue.shift(); + if (!next) { + Logger.Info(TAG, `download queue is empty`); + return undefined; + } + + if (next.isDirectory) { + Logger.Debug(TAG, `downloading dir: ${next.relativePath}`); + let items = await ListFiles(next.relativePath); + let downloadItems = items.map((item: DirectoryEntry): DownloadItem => { + Logger.Info(TAG, `${item.name}`); + return next!.makeChildItem(item.name, item.isDirectory); + }); + + if (DOWNLOAD_DEPTH_FIRST) { + this.queue.unshift(...downloadItems); + } else { + this.queue.push(...downloadItems); + } + } else { + Logger.Debug(TAG, `downloading file: ${next.relativePath}`); + } + return next; + } + + public hasPendingItem() { + return this.queue.length > 0; + } + + public async getNextDownloadFile(): Promise { + do { + let next = await this.shiftNext(); + if (next !== undefined) { + return next; + } + } while (this.hasPendingItem()); + Logger.Info(TAG, `download queue empty`); + return undefined; + } + + public addRootDir() { + let rootItem = new DownloadItem('/', true); + this.queue.push(rootItem); + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 72cdce4..ff52132 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -28,6 +28,12 @@ }, { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" + }, + { + "name": "ohos.permission.FILE_ACCESS_PERSIST" + }, + { + "name": "ohos.permission.PREPARE_APP_TERMINATE" } ], "deviceTypes": [ @@ -75,8 +81,15 @@ "name": "ohos.extension.backup", "resource": "$profile:backup_config" } - ], + ] + }, + { + "name": "StatusBarAbility", + "srcEntry": "./ets/statusbar/StatusBarAbility.ets", + "icon": "$media:layered_image", + "type": "statusBarView", + "exported": true } ] } -} +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..a0a93dd --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 3bd0604..9aab7eb 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -85,4 +85,4 @@ "value": "Upload All" } ] -} +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 498c340..95cb6f8 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,6 +1,7 @@ { "src": [ "pages/Index", - "pages/PcMainPage" + "pages/PcMainPage", + "statusbar/StatusBarPage" ] -} +} \ No newline at end of file diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json index d976b7d..9c8b0a5 100644 --- a/entry/src/main/resources/zh_CN/element/string.json +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -85,4 +85,4 @@ "value": "全部上传" } ] -} +} \ No newline at end of file diff --git a/nextcloud/index.ets b/nextcloud/index.ets deleted file mode 100644 index 70bbc93..0000000 --- a/nextcloud/index.ets +++ /dev/null @@ -1,15 +0,0 @@ -export { NCommon, DEFAULT_CATEGORIES } from './src/main/ets/Common'; - -export { ListFiles } from './src/main/ets/webdav/Files'; - -export { DirectoryEntry } from './src/main/ets/webdav/DirEntryParserStateMachine'; - -export { LoginProcess } from './src/main/ets/Login'; - -export { BasicDataSource } from './src/main/ets/BasicDatasource'; - -export { FilesDataSource } from './src/main/ets/FilesDataSource'; - -export { CreatePath, UploadFile } from './src/main/ets/webdav/Uploader'; - -export { Logger } from './src/main/ets/Logger'; diff --git a/nextcloud/oh-package.json5 b/nextcloud/oh-package.json5 index 73ce640..cbdb133 100644 --- a/nextcloud/oh-package.json5 +++ b/nextcloud/oh-package.json5 @@ -2,10 +2,10 @@ "name": "nextcloud", "version": "1.0.0", "description": "Nextcloud API Wrapper", - "main": "./index.ets", + "main": "./src/main/ets/Index.ets", "author": "Moody Liu", "license": "", "dependencies": { - "@ohos/axios": "^2.2.4", - }, + "@ohos/axios": "^2.2.4" + } } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets new file mode 100644 index 0000000..1696df2 --- /dev/null +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -0,0 +1,21 @@ +// Describes a logged in user + +const TAG = 'SyncEngine/Account'; + +export class Account { + id: string; + username: string; + password: string; + hostname: string; + + constructor(id: string, username: string, password: string, hostname: string) { + this.id = id; + this.username = username; + this.password = password; + this.hostname = hostname; + } + + static ReadFromRecord(): Account { + return new Account('', '', '', ''); + } +} diff --git a/nextcloud/src/main/ets/Login.ets b/nextcloud/src/main/ets/Account/Login.ets similarity index 95% rename from nextcloud/src/main/ets/Login.ets rename to nextcloud/src/main/ets/Account/Login.ets index 6694fee..61f8195 100644 --- a/nextcloud/src/main/ets/Login.ets +++ b/nextcloud/src/main/ets/Account/Login.ets @@ -1,6 +1,6 @@ -import { Logger } from './Logger'; -import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from './nextcloud-api-sdk'; -import { NCommon } from './Common'; +import { Logger } from '../Utils/Logger'; +import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from '../nextcloud-api-sdk'; +import { NCommon } from '../Common'; import { AxiosError } from '@ohos/axios'; const TAG = "Nextcloud-Login"; diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index a704a13..68724db 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -1,7 +1,7 @@ import { deviceInfo } from '@kit.BasicServicesKit'; import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; -import { Logger } from './Logger'; +import { Logger } from './Utils/Logger'; import util from '@ohos.util'; diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets new file mode 100644 index 0000000..ba77987 --- /dev/null +++ b/nextcloud/src/main/ets/Index.ets @@ -0,0 +1,23 @@ +/** + * Nextcloud module entry point + */ + +export { NCommon, DEFAULT_CATEGORIES } from './Common'; + +export { ListFiles } from './webdav/Files'; + +export { DirectoryEntry } from './webdav/DirEntryParserStateMachine'; + +export { LoginProcess } from './Account/Login'; + +export { BasicDataSource } from './Utils/BasicDatasource'; + +export { FilesDataSource } from './Utils/FilesDataSource'; + +export { TransferManager, TransferManagerImpl, TransferTask, TransferType } from './TransferManager'; + +export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; + +export { Logger } from './Utils/Logger'; + +export { InitialiseNextcloud } from './Init'; \ No newline at end of file diff --git a/nextcloud/src/main/ets/Init.ets b/nextcloud/src/main/ets/Init.ets new file mode 100644 index 0000000..8f5bc47 --- /dev/null +++ b/nextcloud/src/main/ets/Init.ets @@ -0,0 +1,39 @@ +/** + * nextcloud initialization + */ +import { DEBUG } from "../../../BuildProfile"; +import { SyncFolderManager } from "./SyncEngine/SyncFolderManager"; +import { Logger } from "./Utils/Logger"; +import { StorageAdapterImpl } from "./Utils/StorageAdapter"; + +const TAG = 'Nextcloud/Init'; + +const DEFAULT_DATABASE_NAME = 'nextcloud_data.db'; +const DEBUG_DATABASE_NAME = 'nextcloud_data-debug.db'; + +class NextcloudImpl { + private storageAdapter: StorageAdapterImpl; + private syncFolderManager: SyncFolderManager; + + constructor() { + Logger.Info(TAG, `constructor`); + this.storageAdapter = new StorageAdapterImpl(); + this.syncFolderManager = new SyncFolderManager(this.storageAdapter); + } + + async initialize(context: Context): Promise { + Logger.Info(TAG, `initialize: enter`); + await this.storageAdapter.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); + await this.storageAdapter.upgradeDatabase(); + await this.syncFolderManager.init(); + } +} + +export async function InitialiseNextcloud(context: Context): Promise { + Logger.Info(TAG, `init`); + Nextcloud = new NextcloudImpl(); + await Nextcloud.initialize(context); +} + + +export let Nextcloud: NextcloudImpl; diff --git a/nextcloud/src/main/ets/Jobs/BaseJob.ets b/nextcloud/src/main/ets/Jobs/BaseJob.ets new file mode 100644 index 0000000..893164c --- /dev/null +++ b/nextcloud/src/main/ets/Jobs/BaseJob.ets @@ -0,0 +1,15 @@ +import { Logger } from "../Index"; + +export class BaseJob { + public readonly jobName: string; + protected readonly TAG: string; + + constructor(name: string) { + this.jobName = name; + this.TAG = `Jobs/${this.jobName}`; + } + + async doWork(): Promise { + Logger.Info(this.TAG, `doWork unimplemented`); + } +} \ No newline at end of file diff --git a/nextcloud/src/main/ets/Jobs/Index.ets b/nextcloud/src/main/ets/Jobs/Index.ets new file mode 100644 index 0000000..fd07bd7 --- /dev/null +++ b/nextcloud/src/main/ets/Jobs/Index.ets @@ -0,0 +1 @@ +export { MkColJob } from './MkColJob'; diff --git a/nextcloud/src/main/ets/Jobs/MkColJob.ets b/nextcloud/src/main/ets/Jobs/MkColJob.ets new file mode 100644 index 0000000..61d6f6d --- /dev/null +++ b/nextcloud/src/main/ets/Jobs/MkColJob.ets @@ -0,0 +1,7 @@ +import { BaseJob } from './BaseJob'; + +export class MkColJob extends BaseJob { + async doWork(): Promise { + return; + } +} \ No newline at end of file diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets new file mode 100644 index 0000000..c06d44b --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets @@ -0,0 +1,24 @@ +// Describes the root sync folder that's in the system +import { Account } from "../Account/Account"; + +const TAG = 'SyncEngine/SyncFolder'; + +export class SyncFolder { + id: string; + alias: string; + systemPath: string; + + ownerAccount: Account | null = null; + + syncTimerId: number = 0; + + constructor(id: string, alias: string, systemPath: string) { + this.id = id; + this.alias = alias; + this.systemPath = systemPath; + } + + static ReadFromRecord(): SyncFolder { + return new SyncFolder('', '', ''); + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets new file mode 100644 index 0000000..3cbabfc --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -0,0 +1,28 @@ +// Manage mappings between Account -=> SyncFolder +import { Logger } from '../Utils/Logger'; +import { StorageAdapterImpl } from '../Utils/StorageAdapter'; + +const TAG = 'SyncEngine/SyncFolderManager'; + +export class SyncFolderManager { + private storageAdapter: StorageAdapterImpl; + + constructor(storageAdapter: StorageAdapterImpl) { + Logger.Info(TAG, `constructor`); + this.storageAdapter = storageAdapter; + } + + public async init(): Promise { + // load accounts from storage + this.loadAccounts(); + + // load folders from storage + this.loadFolders(); + } + + private loadFolders() { + } + + private loadAccounts() { + } +} diff --git a/nextcloud/src/main/ets/TransferManager.ets b/nextcloud/src/main/ets/TransferManager.ets new file mode 100644 index 0000000..d3c05a9 --- /dev/null +++ b/nextcloud/src/main/ets/TransferManager.ets @@ -0,0 +1,54 @@ +import { BasicDataSource } from './Utils/BasicDatasource'; + +export enum TransferType { + DOWNLOAD, + UPLOAD, +} + +@Observed +export class TransferTask { + public readonly type: TransferType; + public readonly filename: string; + + public totalSize: number = 1; + public transferedSize: number = 0; + + public updateProgress(transferSize: number, totalSize: number) { + if (this.totalSize != totalSize) { + this.totalSize = totalSize; + } + this.transferedSize = transferSize; + return this.transferedSize / this.totalSize; + } + + public get progressPercentText() { + let progress = this.transferedSize / this.totalSize; + return `${(progress * 100).toFixed(1)}%`; + } + + constructor(type: TransferType, filename: string) { + this.type = type; + this.filename = filename; + } +} + +@Observed +export class TransferManagerImpl { + private tasks_: BasicDataSource; + + public get tasks() { + return this.tasks_; + } + + constructor() { + this.tasks_ = new BasicDataSource(); + } + + public addTransferTask(type: TransferType, fileName: string): TransferTask { + let task = new TransferTask(type, fileName); + return this.tasks_.prependItem(task); + } +} + + +export let TransferManager = new TransferManagerImpl(); diff --git a/nextcloud/src/main/ets/BasicDatasource.ets b/nextcloud/src/main/ets/Utils/BasicDatasource.ets similarity index 86% rename from nextcloud/src/main/ets/BasicDatasource.ets rename to nextcloud/src/main/ets/Utils/BasicDatasource.ets index e7da625..3c0db22 100644 --- a/nextcloud/src/main/ets/BasicDatasource.ets +++ b/nextcloud/src/main/ets/Utils/BasicDatasource.ets @@ -15,6 +15,17 @@ export class BasicDataSource implements IDataSource { return this.entries.slice(start, start + count); } + public prependItem(item: T): T { + this.entries.unshift(item); + this.notifyDataAdd(0); + return this.entries[0]; + } + + public appendItem(item: T) { + this.entries.push(item); + this.notifyDataAdd(this.entries.length - 1); + } + registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); diff --git a/nextcloud/src/main/ets/FilesDataSource.ets b/nextcloud/src/main/ets/Utils/FilesDataSource.ets similarity index 86% rename from nextcloud/src/main/ets/FilesDataSource.ets rename to nextcloud/src/main/ets/Utils/FilesDataSource.ets index 4c5c42c..8912399 100644 --- a/nextcloud/src/main/ets/FilesDataSource.ets +++ b/nextcloud/src/main/ets/Utils/FilesDataSource.ets @@ -1,6 +1,6 @@ import { BasicDataSource } from './BasicDatasource'; -import { DirectoryEntry } from './webdav/DirEntryParserStateMachine'; -import { ListFiles } from './webdav/Files'; +import { DirectoryEntry } from '../webdav/DirEntryParserStateMachine'; +import { ListFiles } from '../webdav/Files'; export class FilesDataSource extends BasicDataSource { constructor(path: string) { diff --git a/nextcloud/src/main/ets/Logger.ts b/nextcloud/src/main/ets/Utils/Logger.ts similarity index 100% rename from nextcloud/src/main/ets/Logger.ts rename to nextcloud/src/main/ets/Utils/Logger.ts diff --git a/nextcloud/src/main/ets/Utils/StorageAdapter.ets b/nextcloud/src/main/ets/Utils/StorageAdapter.ets new file mode 100644 index 0000000..2bf2c46 --- /dev/null +++ b/nextcloud/src/main/ets/Utils/StorageAdapter.ets @@ -0,0 +1,92 @@ +/** + * Adapter of DataShare relational DB + */ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from './Logger'; + +const TAG = 'Utils/StorageAdapter'; + + +const SQL_CREATE_TABLE = ` +CREATE TABLE IF NOT EXISTS Accounts ( + account_id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS Folders ( + folder_id INTEGER PRIMARY KEY AUTOINCREMENT, + system_path TEXT NOT NULL, + alias TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS FolderSyncInfo ( + folder_path INTEGER PRIMARY KEY AUTOINCREMENT, + etag TEXT NOT NULL +); +`; + +export class StorageAdapterImpl { + store: relationalStore.RdbStore | undefined = undefined; + + async initDataStore(context: Context, storageName: string) { + Logger.Info(TAG, `initDataStore: enter, storageName: ${storageName}`); + let storeConfig: relationalStore.StoreConfig = { + name: storageName, + securityLevel: relationalStore.SecurityLevel.S3, + }; + + if (this.store) { + Logger.Error(TAG, `initDataStore: duplicated init`); + return; + } + + try { + this.store = await relationalStore.getRdbStore(context, storeConfig); + } catch (e) { + Logger.Error(TAG, `initDataStore: Failed to get RdbStore: ${JSON.stringify(e)}`); + return; + } + + Logger.Info(TAG, 'initDataStore: completed'); + } + + async upgradeDatabase(): Promise { + Logger.Info(TAG, 'upgradeDatabase: enter'); + if (!this.store) { + Logger.Error(TAG, `upgradeDatabase: store is undefined`); + return; + } + + let transaction: relationalStore.Transaction; + try { + transaction = await this.store.createTransaction(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to create transaction: ${JSON.stringify(error)}`); + return; + } + try { + let storeVersion = await transaction.execute('PRAGMA user_version'); + + if (storeVersion === 0) { + await transaction.execute(SQL_CREATE_TABLE); + await transaction.execute('PRAGMA user_version = 1'); + storeVersion = 1; + } + } catch (e) { + Logger.Error(TAG, `upgradeDatabase: failed to execute sql: ${JSON.stringify(e)}`); + await transaction.rollback(); + return; + } + + try { + await transaction.commit(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to commit transaction: ${JSON.stringify(error)}`); + } + Logger.Info(TAG, 'upgradeDatabase: completed'); + this.store.commit(); + // 请确保获取到RdbStore实例,完成数据表创建后,再进行数据库的增、删、改、查等操作 + } +} diff --git a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts index c4cbc7c..e51e8ec 100644 --- a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts +++ b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts @@ -1,5 +1,5 @@ import { DAV_PROPERTY_TYPES, DavPropsType, DavResponse, DavResponseRoot } from './DavTypes'; -import { Logger } from '../Logger'; +import { Logger } from '../Utils/Logger'; const TAG = "Nextcloud-DirEntryParser"; diff --git a/nextcloud/src/main/ets/webdav/Files.ets b/nextcloud/src/main/ets/webdav/Files.ets index cbdb9e7..fb7b54c 100644 --- a/nextcloud/src/main/ets/webdav/Files.ets +++ b/nextcloud/src/main/ets/webdav/Files.ets @@ -1,5 +1,5 @@ import { rcp } from '@kit.RemoteCommunicationKit'; -import { Logger } from '../Logger'; +import { Logger } from '../Utils/Logger'; import { NCommon } from '../Common'; import { xml } from '@kit.ArkTS'; import { DirectoryEntry, DirEntryParserStateMachine } from './DirEntryParserStateMachine'; diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets index 4f40742..7bcf3c0 100644 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ b/nextcloud/src/main/ets/webdav/Uploader.ets @@ -1,13 +1,14 @@ import { rcp } from '@kit.RemoteCommunicationKit'; import { NCommon } from '../Common'; import fs from '@ohos.file.fs'; -import { Logger } from '../Logger'; +import { Logger } from '../Utils/Logger'; +import { TransferManager, TransferTask, TransferType } from '../TransferManager'; const TAG = "Nextcloud-Upload"; -function CreateSession(extraHeaders?: Map): rcp.Session { +function CreateSession(extraHeaders?: Map, eventsHandler?: rcp.HttpEventsHandler): rcp.Session { let headers: rcp.RequestHeaders = {}; if (extraHeaders !== undefined) { @@ -24,11 +25,7 @@ function CreateSession(extraHeaders?: Map): rcp.Session { headers: headers, requestConfiguration: { tracing: { - httpEventsHandler: { - onUploadProgress: (progress: number) => { - Logger.Info(TAG, "Upload progress: " + progress); - }, - } + httpEventsHandler: eventsHandler, } } } @@ -98,6 +95,33 @@ export async function CreatePath(path: string): Promise { return respCode; } +export async function DownloadFile(relativePath: string, localPath: string): Promise { + let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, relativePath); + + let eventsHandler: rcp.HttpEventsHandler = { + onDownloadProgress: (transferredBytes: number, totalBytes: number) => { + task.updateProgress(transferredBytes, totalBytes); + } + }; + + let session = CreateSession(undefined, eventsHandler); + let downloadToFileOptions: rcp.DownloadToFile = { + kind: "file", + file: localPath, + }; + + session.downloadToFile( + "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + relativePath, + downloadToFileOptions + ).then(() => { + task.updateProgress(task.totalSize, task.totalSize); + }).catch((error: BusinessError) => { + Logger.Error(TAG, `Download failed: ${relativePath}, error: ${JSON.stringify(error)}`); + }); + + session.close(); +} + export async function UploadFile(filename: string, mTime: number, path: string, uri: string): Promise { let extraHeaders = new Map(); extraHeaders.set('X-OC-MTime', `${Math.round(mTime / 1000)}`); diff --git a/watch_logs.cache.json b/watch_logs.cache.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/watch_logs.cache.json @@ -0,0 +1 @@ +[] \ No newline at end of file From c9e6efcc4e22603266571a35e45621b4c99e81e4 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Fri, 9 Jan 2026 01:43:21 +0800 Subject: [PATCH 05/16] wip: sync folder manager --- .../main/ets/entryability/EntryAbility.ets | 1 - nextcloud/src/main/ets/Account/Account.ets | 20 +- nextcloud/src/main/ets/Account/Login.ets | 7 +- nextcloud/src/main/ets/Common.ets | 4 +- nextcloud/src/main/ets/Database/DBManager.ets | 184 ++++++++++++++++++ .../src/main/ets/Database/Types/DBAccount.ets | 60 ++++++ .../main/ets/Database/Types/DBSyncFolder.ets | 61 ++++++ nextcloud/src/main/ets/Index.ets | 2 +- nextcloud/src/main/ets/Jobs/BaseJob.ets | 2 +- nextcloud/src/main/ets/{Init.ets => Main.ets} | 16 +- .../src/main/ets/SyncEngine/SyncFolder.ets | 20 +- .../main/ets/SyncEngine/SyncFolderManager.ets | 50 +++-- nextcloud/src/main/ets/Utils/CommonUtils.ets | 7 + .../src/main/ets/Utils/StorageAdapter.ets | 92 --------- nextcloud/src/main/ets/webdav/Uploader.ets | 4 +- 15 files changed, 381 insertions(+), 149 deletions(-) create mode 100644 nextcloud/src/main/ets/Database/DBManager.ets create mode 100644 nextcloud/src/main/ets/Database/Types/DBAccount.ets create mode 100644 nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets rename nextcloud/src/main/ets/{Init.ets => Main.ets} (63%) create mode 100644 nextcloud/src/main/ets/Utils/CommonUtils.ets delete mode 100644 nextcloud/src/main/ets/Utils/StorageAdapter.ets diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 569d7b8..2068e5b 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -20,7 +20,6 @@ export default class EntryAbility extends UIAbility { } catch (error) { Logger.Error(TAG, `failed to set color mode!`); } - NCommon.Initialise(this.context); await InitialiseNextcloud(this.context); } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index 1696df2..cacbbc1 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -1,21 +1,13 @@ -// Describes a logged in user +import { DBAccount } from '../Database/Types/DBAccount'; +import { Logger } from '../Utils/Logger'; const TAG = 'SyncEngine/Account'; export class Account { - id: string; - username: string; - password: string; - hostname: string; + dbAccount: DBAccount; - constructor(id: string, username: string, password: string, hostname: string) { - this.id = id; - this.username = username; - this.password = password; - this.hostname = hostname; - } - - static ReadFromRecord(): Account { - return new Account('', '', '', ''); + constructor(dbAccount: DBAccount) { + Logger.Info(TAG, `constructor: enter`); + this.dbAccount = dbAccount; } } diff --git a/nextcloud/src/main/ets/Account/Login.ets b/nextcloud/src/main/ets/Account/Login.ets index 61f8195..6db79ad 100644 --- a/nextcloud/src/main/ets/Account/Login.ets +++ b/nextcloud/src/main/ets/Account/Login.ets @@ -2,8 +2,9 @@ import { Logger } from '../Utils/Logger'; import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from '../nextcloud-api-sdk'; import { NCommon } from '../Common'; import { AxiosError } from '@ohos/axios'; +import { AsyncSleep } from '../Utils/CommonUtils'; -const TAG = "Nextcloud-Login"; +const TAG = 'Account/Login'; interface InitLoginResult { success: boolean; @@ -40,7 +41,7 @@ class LoginProcess { return { success: true, loginUrl: flow.login }; } catch (e) { let axiosError: AxiosError = e as AxiosError; - Logger.Error(TAG, "LoginV2Init failed: " + e); + Logger.Error(TAG, "LoginV2Init failed: " + JSON.stringify(e)); if (Number(axiosError.code) === 2300006) { return { success: false, errorMsg: (JSON.parse(axiosError.message) as Error).message }; } else { @@ -64,7 +65,7 @@ class LoginProcess { cancelled: true }; } - await new Promise(resolve => setTimeout(resolve, 1500)); + await AsyncSleep(1500); result = await this.loginPollOnce(); } diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index 68724db..a29c4ab 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -62,7 +62,7 @@ class NPreference { ]; private pref: preferences.Preferences; - constructor(context: common.UIAbilityContext) { + constructor(context: Context) { this.pref = preferences.getPreferencesSync(context, { name: "nextcloud" }); } @@ -185,7 +185,7 @@ export namespace NCommon { * Initialise the common module * @param context The context to use for preferences */ - export function Initialise(context: common.UIAbilityContext) { + export function Initialise(context: Context) { Logger.Info(TAG, "Common initialised with User-Agent: " + UserAgent()); Preference = new NPreference(context); } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets new file mode 100644 index 0000000..41b30e1 --- /dev/null +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -0,0 +1,184 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../Utils/Logger'; +import { DBAccount, DBAccountFactory } from './Types/DBAccount'; +import { DBSyncFolderFactory } from './Types/DBSyncFolder'; + +const TAG = 'Utils/StorageAdapter'; + +export interface ColumnDescriptor { + column: string; + type: string; + attrs?: string; +} + +export type ColumnDescriptors = ColumnDescriptor[]; + +export interface DBObject { + getTableName(): string; + + toBucket(): relationalStore.ValuesBucket; +} + +interface DBTableDescriptor { + getTableName(): string; + + getColumnDesc(): ColumnDescriptors; +} + +export interface DBObjectCreator extends DBTableDescriptor { + createFromBucket(bucket: relationalStore.ValuesBucket): T; +} + + +export class DBManager { + store: relationalStore.RdbStore | undefined = undefined; + + static generateTableCreationScript(descriptor: DBTableDescriptor): string { + let columnsDef: string[] = []; + for (let col of descriptor.getColumnDesc()) { + let colDef = `${col.column} ${col.type}`; + if (col.attrs) { + colDef += ` ${col.attrs}`; + } + columnsDef.push(colDef); + } + return `CREATE TABLE IF NOT EXISTS ${descriptor.getTableName()} (${columnsDef.join(', ')});`; + } + + static gatherTableCreationScripts(): string[] { + let result: string[] = []; + result.push(DBManager.generateTableCreationScript(DBAccountFactory)); + result.push(DBManager.generateTableCreationScript(DBSyncFolderFactory)); + return result; + } + + async initDataStore(context: Context, storageName: string) { + Logger.Info(TAG, `initDataStore: enter, storageName: ${storageName}`); + let storeConfig: relationalStore.StoreConfig = { + name: storageName, + securityLevel: relationalStore.SecurityLevel.S3, + }; + + if (this.store) { + Logger.Error(TAG, `initDataStore: duplicated init`); + return; + } + + try { + this.store = await relationalStore.getRdbStore(context, storeConfig); + } catch (e) { + Logger.Error(TAG, `initDataStore: Failed to get RdbStore: ${JSON.stringify(e)}`); + return; + } + + Logger.Info(TAG, 'initDataStore: completed'); + } + + async upgradeDatabase(): Promise { + Logger.Info(TAG, 'upgradeDatabase: enter'); + if (!this.store) { + Logger.Error(TAG, `upgradeDatabase: store is undefined`); + return; + } + + let transaction: relationalStore.Transaction; + try { + transaction = await this.store.createTransaction(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to create transaction: ${JSON.stringify(error)}`); + return; + } + try { + let storeVersion = await transaction.execute('PRAGMA user_version'); + if (storeVersion === 0) { + const creationScripts = DBManager.gatherTableCreationScripts(); + for (let sql of creationScripts) { + await transaction.execute(sql); + } + await transaction.execute('PRAGMA user_version = 1'); + storeVersion = 1; + } + } catch (e) { + Logger.Error(TAG, `upgradeDatabase: failed to execute sql: ${JSON.stringify(e)}`); + await transaction.rollback(); + return; + } + + try { + await transaction.commit(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to commit transaction: ${JSON.stringify(error)}`); + } + Logger.Info(TAG, 'upgradeDatabase: completed'); + } + + async doTransaction(operations: (transaction: relationalStore.Transaction) => Promise) { + Logger.Debug(TAG, 'doTransaction: enter'); + if (!this.store) { + Logger.Error(TAG, `doTransaction: store is undefined`); + return; + } + + let transaction: relationalStore.Transaction; + try { + transaction = await this.store.createTransaction(); + } catch (error) { + Logger.Error(TAG, `doTransaction: failed to create transaction: ${JSON.stringify(error)}`); + return; + } + + try { + await operations(transaction); + } catch (e) { + Logger.Error(TAG, `doTransaction: failed to execute operations: ${JSON.stringify(e)}`); + await transaction.rollback(); + return; + } + + try { + await transaction.commit(); + } catch (error) { + Logger.Error(TAG, `doTransaction: failed to commit transaction: ${JSON.stringify(error)}`); + } + Logger.Debug(TAG, 'doTransaction: completed'); + } + + async getObjects(factory: DBObjectCreator): Promise { + Logger.Info(TAG, 'getObjects: enter'); + if (!this.store) { + Logger.Error(TAG, `getObjects: store is undefined`); + return []; + } + + let result: T[] = []; + try { + let resultSet = await this.store.query(new relationalStore.RdbPredicates(factory.getTableName())); + while (resultSet.goToNextRow()) { + let bucket = resultSet.getRow(); + result.push(factory.createFromBucket(bucket)); + } + } catch (error) { + Logger.Error(TAG, `getObjects: failed to read result set: ${JSON.stringify(error)}`); + } + + Logger.Info(TAG, `getObjects: completed, found ${result.length} accounts`); + return result; + } + + async storeObject(object: DBObject): Promise { + Logger.Info(TAG, 'storeObject: enter'); + if (!this.store) { + Logger.Error(TAG, `storeObject: store is undefined`); + return; + } + + let bucket = object.toBucket(); + try { + await this.store.insert(object.getTableName(), bucket); + } catch (error) { + Logger.Error(TAG, `storeObject: failed to insert account: ${JSON.stringify(error)}`); + return; + } + Logger.Info(TAG, 'storeObject: completed'); + } +} diff --git a/nextcloud/src/main/ets/Database/Types/DBAccount.ets b/nextcloud/src/main/ets/Database/Types/DBAccount.ets new file mode 100644 index 0000000..7231b61 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/DBAccount.ets @@ -0,0 +1,60 @@ +import { Logger } from '../../Utils/Logger'; +import { DBObject, ColumnDescriptors, DBObjectCreator } from '../DBManager'; +import { relationalStore } from '@kit.ArkData'; + +const TAG = 'Database/DBAccount'; + +export class DBAccount implements DBObject { + static readonly DB_TABLE_NAME: string = 'Accounts'; + id: number; + username: string; + password: string; + hostname: string; + + constructor(id: number, username: string, password: string, hostname: string) { + this.id = id; + this.username = username; + this.password = password; + this.hostname = hostname; + } + + public getTableName(): string { + return DBAccount.DB_TABLE_NAME; + } + + public toBucket(): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + bucket['account_id'] = this.id; + bucket['username'] = this.username; + bucket['password'] = this.password; + bucket['hostname'] = this.hostname; + return bucket; + } +} + +class DBAccountFactoryImpl implements DBObjectCreator { + getTableName(): string { + return DBAccount.DB_TABLE_NAME; + } + + createFromBucket(bucket: relationalStore.ValuesBucket): DBAccount { + Logger.Info(TAG, `createFromBucket: enter, row: ${JSON.stringify(bucket)}`); + let id = bucket['account_id'] as number; + let username = bucket['username'] as string; + let password = bucket['password'] as string; + let hostname = bucket['hostname'] as string; + return new DBAccount(id, username, password, hostname); + } + + getColumnDesc(): ColumnDescriptors { + const desc: ColumnDescriptors = [ + { column: 'account_id', type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + { column: 'hostname', type: 'TEXT', attrs: 'NOT NULL' }, + { column: 'username', type: 'TEXT', attrs: 'NOT NULL' }, + { column: 'password', type: 'TEXT', attrs: 'NOT NULL' }, + ]; + return desc; + } +} + +export const DBAccountFactory = new DBAccountFactoryImpl(); diff --git a/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets b/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets new file mode 100644 index 0000000..53c755b --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets @@ -0,0 +1,61 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { ColumnDescriptors, DBObject, DBObjectCreator } from '../DBManager'; + +const TAG = 'Database/DBSyncFolder'; + +export class DBSyncFolder implements DBObject { + static readonly DB_TABLE_NAME: string = 'Folders'; + id: number; + systemPath: string; + alias: string; + accountId: number; + + constructor(id: number, systemPath: string, alias: string, account_id: number) { + this.id = id; + this.systemPath = systemPath; + this.alias = alias; + this.accountId = account_id; + } + + public getTableName(): string { + return DBSyncFolder.DB_TABLE_NAME; + } + + public toBucket(): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + bucket['folder_id'] = this.id; + bucket['system_path'] = this.systemPath; + bucket['alias'] = this.alias; + bucket['account_id'] = this.accountId; + return bucket; + } +} + +class DBSyncFolderFactoryImpl implements DBObjectCreator { + static readonly DB_COLUMN_DESC: ColumnDescriptors = [ + { column: 'folder_id', type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + { column: 'system_path', type: 'TEXT', attrs: 'NOT NULL' }, + { column: 'alias', type: 'TEXT', attrs: 'NOT NULL' }, + { column: 'account_id', type: 'INTEGER', attrs: 'NOT NULL' }, + ]; + + getTableName(): string { + return DBSyncFolder.DB_TABLE_NAME; + } + + createFromBucket(bucket: relationalStore.ValuesBucket): DBSyncFolder { + Logger.Info(TAG, `createFromBucket: enter, row: ${JSON.stringify(bucket)}`); + let id = bucket['folder_id'] as number; + let systemPath = bucket['system_path'] as string; + let alias = bucket['alias'] as string; + let account_id = bucket['account_id'] as number; + return new DBSyncFolder(id, systemPath, alias, account_id); + } + + getColumnDesc(): ColumnDescriptors { + return DBSyncFolderFactoryImpl.DB_COLUMN_DESC; + } +} + +export const DBSyncFolderFactory = new DBSyncFolderFactoryImpl(); diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index ba77987..3b16c8c 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -20,4 +20,4 @@ export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; export { Logger } from './Utils/Logger'; -export { InitialiseNextcloud } from './Init'; \ No newline at end of file +export { InitialiseNextcloud } from './Main'; diff --git a/nextcloud/src/main/ets/Jobs/BaseJob.ets b/nextcloud/src/main/ets/Jobs/BaseJob.ets index 893164c..12c8d71 100644 --- a/nextcloud/src/main/ets/Jobs/BaseJob.ets +++ b/nextcloud/src/main/ets/Jobs/BaseJob.ets @@ -12,4 +12,4 @@ export class BaseJob { async doWork(): Promise { Logger.Info(this.TAG, `doWork unimplemented`); } -} \ No newline at end of file +} diff --git a/nextcloud/src/main/ets/Init.ets b/nextcloud/src/main/ets/Main.ets similarity index 63% rename from nextcloud/src/main/ets/Init.ets rename to nextcloud/src/main/ets/Main.ets index 8f5bc47..327fc58 100644 --- a/nextcloud/src/main/ets/Init.ets +++ b/nextcloud/src/main/ets/Main.ets @@ -4,7 +4,8 @@ import { DEBUG } from "../../../BuildProfile"; import { SyncFolderManager } from "./SyncEngine/SyncFolderManager"; import { Logger } from "./Utils/Logger"; -import { StorageAdapterImpl } from "./Utils/StorageAdapter"; +import { DBManager } from "./Database/DBManager"; +import { NCommon } from "./Common"; const TAG = 'Nextcloud/Init'; @@ -12,25 +13,26 @@ const DEFAULT_DATABASE_NAME = 'nextcloud_data.db'; const DEBUG_DATABASE_NAME = 'nextcloud_data-debug.db'; class NextcloudImpl { - private storageAdapter: StorageAdapterImpl; + private dbManager: DBManager; private syncFolderManager: SyncFolderManager; constructor() { Logger.Info(TAG, `constructor`); - this.storageAdapter = new StorageAdapterImpl(); - this.syncFolderManager = new SyncFolderManager(this.storageAdapter); + this.dbManager = new DBManager(); + this.syncFolderManager = new SyncFolderManager(this.dbManager); } async initialize(context: Context): Promise { Logger.Info(TAG, `initialize: enter`); - await this.storageAdapter.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); - await this.storageAdapter.upgradeDatabase(); - await this.syncFolderManager.init(); + await this.dbManager.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); + await this.dbManager.upgradeDatabase(); + await this.syncFolderManager.loadData(); } } export async function InitialiseNextcloud(context: Context): Promise { Logger.Info(TAG, `init`); + NCommon.Initialise(context); Nextcloud = new NextcloudImpl(); await Nextcloud.initialize(context); } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets index c06d44b..61e0652 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets @@ -1,24 +1,16 @@ // Describes the root sync folder that's in the system import { Account } from "../Account/Account"; +import { DBSyncFolder } from "../Database/Types/DBSyncFolder"; const TAG = 'SyncEngine/SyncFolder'; export class SyncFolder { - id: string; - alias: string; - systemPath: string; - - ownerAccount: Account | null = null; - + ownerAccount: Account; + dbSyncFolder: DBSyncFolder; syncTimerId: number = 0; - constructor(id: string, alias: string, systemPath: string) { - this.id = id; - this.alias = alias; - this.systemPath = systemPath; - } - - static ReadFromRecord(): SyncFolder { - return new SyncFolder('', '', ''); + constructor(dbSyncFolder: DBSyncFolder, account: Account) { + this.dbSyncFolder = dbSyncFolder; + this.ownerAccount = account; } } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 3cbabfc..7c4d8da 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -1,28 +1,54 @@ // Manage mappings between Account -=> SyncFolder import { Logger } from '../Utils/Logger'; -import { StorageAdapterImpl } from '../Utils/StorageAdapter'; +import { DBManager } from '../Database/DBManager'; +import { Account } from '../Account/Account'; +import { DBAccount, DBAccountFactory } from '../Database/Types/DBAccount'; +import { NCommon } from '../Common'; +import { DBSyncFolder, DBSyncFolderFactory } from '../Database/Types/DBSyncFolder'; +import { SyncFolder } from './SyncFolder'; const TAG = 'SyncEngine/SyncFolderManager'; export class SyncFolderManager { - private storageAdapter: StorageAdapterImpl; + private dbManager: DBManager; + private accounts: Map = new Map(); + private folders: Map = new Map(); - constructor(storageAdapter: StorageAdapterImpl) { + constructor(dbManager: DBManager) { Logger.Info(TAG, `constructor`); - this.storageAdapter = storageAdapter; + this.dbManager = dbManager; } - public async init(): Promise { - // load accounts from storage - this.loadAccounts(); - - // load folders from storage - this.loadFolders(); + public async loadData(): Promise { + await this.loadAccounts(); + await this.loadSyncFolders(); } - private loadFolders() { + private async loadSyncFolders() { + let dbFolders = await this.dbManager.getObjects(DBSyncFolderFactory); + for (let dbFolder of dbFolders) { + let ownerAccount = this.accounts.get(dbFolder.accountId); + if (!ownerAccount) { + Logger.Error(TAG, `loadSyncFolders: no owner account found for folder id ${dbFolder.id}, account id ${dbFolder.accountId}`); + continue; + } + let syncFolder = new SyncFolder(dbFolder, ownerAccount); + this.folders.set(dbFolder.id, syncFolder); + } } - private loadAccounts() { + private async loadAccounts(): Promise { + let dbAccounts = await this.dbManager.getObjects(DBAccountFactory); + for (let dbAccount of dbAccounts) { + let account = new Account(dbAccount); + this.accounts.set(dbAccount.id, account); + } + + if (NCommon.Preference.username.length > 0 && dbAccounts.length === 0) { + Logger.Info(TAG, `loadAccounts: no accounts found, migrate default account`); + let dbAccount = new DBAccount(0, NCommon.Preference.username, NCommon.Preference.password, NCommon.Preference.hostname); + this.accounts.set(dbAccount.id, new Account(dbAccount)); + await this.dbManager.storeObject(dbAccount); + } } } diff --git a/nextcloud/src/main/ets/Utils/CommonUtils.ets b/nextcloud/src/main/ets/Utils/CommonUtils.ets new file mode 100644 index 0000000..d30005d --- /dev/null +++ b/nextcloud/src/main/ets/Utils/CommonUtils.ets @@ -0,0 +1,7 @@ +export async function AsyncSleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} diff --git a/nextcloud/src/main/ets/Utils/StorageAdapter.ets b/nextcloud/src/main/ets/Utils/StorageAdapter.ets deleted file mode 100644 index 2bf2c46..0000000 --- a/nextcloud/src/main/ets/Utils/StorageAdapter.ets +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Adapter of DataShare relational DB - */ -import { relationalStore } from '@kit.ArkData'; -import { Logger } from './Logger'; - -const TAG = 'Utils/StorageAdapter'; - - -const SQL_CREATE_TABLE = ` -CREATE TABLE IF NOT EXISTS Accounts ( - account_id INTEGER PRIMARY KEY AUTOINCREMENT, - hostname TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS Folders ( - folder_id INTEGER PRIMARY KEY AUTOINCREMENT, - system_path TEXT NOT NULL, - alias TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS FolderSyncInfo ( - folder_path INTEGER PRIMARY KEY AUTOINCREMENT, - etag TEXT NOT NULL -); -`; - -export class StorageAdapterImpl { - store: relationalStore.RdbStore | undefined = undefined; - - async initDataStore(context: Context, storageName: string) { - Logger.Info(TAG, `initDataStore: enter, storageName: ${storageName}`); - let storeConfig: relationalStore.StoreConfig = { - name: storageName, - securityLevel: relationalStore.SecurityLevel.S3, - }; - - if (this.store) { - Logger.Error(TAG, `initDataStore: duplicated init`); - return; - } - - try { - this.store = await relationalStore.getRdbStore(context, storeConfig); - } catch (e) { - Logger.Error(TAG, `initDataStore: Failed to get RdbStore: ${JSON.stringify(e)}`); - return; - } - - Logger.Info(TAG, 'initDataStore: completed'); - } - - async upgradeDatabase(): Promise { - Logger.Info(TAG, 'upgradeDatabase: enter'); - if (!this.store) { - Logger.Error(TAG, `upgradeDatabase: store is undefined`); - return; - } - - let transaction: relationalStore.Transaction; - try { - transaction = await this.store.createTransaction(); - } catch (error) { - Logger.Error(TAG, `upgradeDatabase: failed to create transaction: ${JSON.stringify(error)}`); - return; - } - try { - let storeVersion = await transaction.execute('PRAGMA user_version'); - - if (storeVersion === 0) { - await transaction.execute(SQL_CREATE_TABLE); - await transaction.execute('PRAGMA user_version = 1'); - storeVersion = 1; - } - } catch (e) { - Logger.Error(TAG, `upgradeDatabase: failed to execute sql: ${JSON.stringify(e)}`); - await transaction.rollback(); - return; - } - - try { - await transaction.commit(); - } catch (error) { - Logger.Error(TAG, `upgradeDatabase: failed to commit transaction: ${JSON.stringify(error)}`); - } - Logger.Info(TAG, 'upgradeDatabase: completed'); - this.store.commit(); - // 请确保获取到RdbStore实例,完成数据表创建后,再进行数据库的增、删、改、查等操作 - } -} diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets index 7bcf3c0..3233e9f 100644 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ b/nextcloud/src/main/ets/webdav/Uploader.ets @@ -99,7 +99,7 @@ export async function DownloadFile(relativePath: string, localPath: string): Pro let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, relativePath); let eventsHandler: rcp.HttpEventsHandler = { - onDownloadProgress: (transferredBytes: number, totalBytes: number) => { + onDownloadProgress: (totalBytes: number, transferredBytes: number) => { task.updateProgress(transferredBytes, totalBytes); } }; @@ -110,7 +110,7 @@ export async function DownloadFile(relativePath: string, localPath: string): Pro file: localPath, }; - session.downloadToFile( + await session.downloadToFile( "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + relativePath, downloadToFileOptions ).then(() => { From e640ab2836adfec060efa1efb879854e7460db6a Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sat, 10 Jan 2026 20:54:29 +0800 Subject: [PATCH 06/16] get ready for multi-user/multi-dir support --- .../main/ets/entryability/EntryAbility.ets | 17 +-- .../entrybackupability/EntryBackupAbility.ets | 11 +- entry/src/main/ets/models/PhotoItem.ets | 13 +- entry/src/main/ets/pages/FolderPage.ets | 32 +++-- entry/src/main/ets/pages/Index.ets | 54 ++++---- entry/src/main/ets/pages/LoginPage.ets | 119 ++++++++---------- entry/src/main/ets/pages/PcMainPage.ets | 18 ++- entry/src/main/ets/pages/PreviewPage.ets | 13 +- entry/src/main/ets/pages/SettingsPage.ets | 9 +- entry/src/main/ets/pages/UploadPage.ets | 4 +- entry/src/main/ets/utils/Common.ets | 2 + entry/src/main/ets/utils/DownloadQueue.ets | 50 ++++---- .../main/ets/utils/NotificationManager.ets | 65 +++++----- .../src/main/ets/utils/PermissionManager.ets | 71 +++++++---- ...adScheduler.ets => PhotoAssetsManager.ets} | 61 +++++---- entry/src/main/ets/utils/PhotoMonitor.ets | 85 +++++++------ entry/src/main/ets/utils/Utils.ets | 30 +++++ nextcloud/src/main/ets/Account/Account.ets | 40 +++++- .../ets/Account/{Login.ets => LoginFlow.ets} | 62 +++++---- nextcloud/src/main/ets/Common.ets | 106 ++++------------ nextcloud/src/main/ets/Database/DBManager.ets | 35 +++--- .../src/main/ets/Database/Types/DBAccount.ets | 10 +- .../main/ets/Database/Types/DBSyncFolder.ets | 4 +- nextcloud/src/main/ets/Index.ets | 8 +- nextcloud/src/main/ets/Main.ets | 9 ++ .../main/ets/SyncEngine/SyncFolderManager.ets | 38 ++++-- nextcloud/src/main/ets/Utils/CommonUtils.ets | 9 ++ .../src/main/ets/Utils/FilesDataSource.ets | 5 +- nextcloud/src/main/ets/Utils/Result.ets | 25 ++++ nextcloud/src/main/ets/webdav/Files.ets | 12 +- nextcloud/src/main/ets/webdav/Uploader.ets | 36 +++--- 31 files changed, 576 insertions(+), 477 deletions(-) rename entry/src/main/ets/utils/{UploadScheduler.ets => PhotoAssetsManager.ets} (69%) rename nextcloud/src/main/ets/Account/{Login.ets => LoginFlow.ets} (61%) create mode 100644 nextcloud/src/main/ets/Utils/Result.ets diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 2068e5b..8680073 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,6 +1,6 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; +import { AsyncSleep, InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; import { Notification } from '../utils/NotificationManager'; @@ -11,7 +11,7 @@ import { statusBarManager } from '@kit.DeskTopExtensionKit'; const TAG = 'EntryAbility'; export default class EntryAbility extends UIAbility { - id: number = 0; + private initCompleted: boolean = false; async onCreate(_want: Want, _launchParam: AbilityConstant.LaunchParam) { Logger.Info(TAG, 'Ability onCreate'); @@ -21,6 +21,7 @@ export default class EntryAbility extends UIAbility { Logger.Error(TAG, `failed to set color mode!`); } await InitialiseNextcloud(this.context); + this.initCompleted = true; } onDestroy() { @@ -28,15 +29,16 @@ export default class EntryAbility extends UIAbility { } convertToPixelMap(res: Resource) { - // 获取 resourceManager const resManager = this.context.resourceManager; - - // 获取 DrawableDescriptor 并转换为 PixelMap - const drawableDesc = resManager?.getDrawableDescriptor(res.id) as DrawableDescriptor; - return drawableDesc?.getPixelMap(); // 直接获取 PixelMap + const drawableDesc = resManager?.getDrawableDescriptor(res.id); + return drawableDesc?.getPixelMap(); } async onWindowStageCreate(windowStage: window.WindowStage) { + while (!this.initCompleted) { + Logger.Info(TAG, 'Waiting for initialization to complete...'); + await AsyncSleep(100); + } Logger.Info(TAG, 'Main window is created, set main page for this ability'); let context = this.context; @@ -97,6 +99,7 @@ export default class EntryAbility extends UIAbility { } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); + PersistentStorage.persistProp(Constants.PHONE_ACTIVE_ACCOUNT_ID, 0); } Logger.Info(TAG, 'Succeeded in loading the content.'); } catch (e) { diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets index 26d5ae4..5386247 100644 --- a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -5,9 +5,7 @@ import { Logger, NCommon } from 'nextcloud'; const TAG = 'entry/Backup'; interface BackupContentV1 { - server: string; - username: string; - appPassword: string; + nothing: 'to backup'; } interface BackupObject { @@ -20,9 +18,7 @@ export default class EntryBackupAbility extends BackupExtensionAbility { let object: BackupObject = { version: 1, settings: { - server: NCommon.Preference.hostname, - username: NCommon.Preference.username, - appPassword: NCommon.Preference.password + nothing: 'to backup' } }; let jsonString = JSON.stringify(object); @@ -49,9 +45,6 @@ export default class EntryBackupAbility extends BackupExtensionAbility { } let object: BackupObject = JSON.parse(data); - NCommon.Preference.hostname = object.settings.server; - NCommon.Preference.username = object.settings.username; - NCommon.Preference.password = object.settings.appPassword; Logger.Info(TAG, `onRestore completed with data: ${JSON.stringify(object)}`); file.close(); diff --git a/entry/src/main/ets/models/PhotoItem.ets b/entry/src/main/ets/models/PhotoItem.ets index 098428b..a63c1cf 100644 --- a/entry/src/main/ets/models/PhotoItem.ets +++ b/entry/src/main/ets/models/PhotoItem.ets @@ -1,11 +1,11 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { CreatePath, Logger, UploadFile } from 'nextcloud'; +import { CreatePath, Logger, Nextcloud, UploadFile } from 'nextcloud'; import { promptAction } from '@kit.ArkUI'; import { FormatTime } from '../utils/Utils'; import { image } from '@kit.ImageKit'; const TAG = 'PhotoItem'; -const DEFAULT_FORMAT = 'yyyy/MM/'; +const DEFAULT_FORMAT = 'yyyy/MM/'; export enum UploadStatus { None, @@ -25,6 +25,11 @@ export enum UploadResult { export class PhotoItem { uploadStatus: UploadStatus = UploadStatus.None; private innerAsset: PHelper.PhotoAsset; + // BEGIN hack + private readonly activeAccountId: number = 0; + private readonly account = Nextcloud.getAccountById(this.activeAccountId); + + // END hack constructor(asset: PHelper.PhotoAsset) { this.innerAsset = asset; @@ -69,7 +74,7 @@ export class PhotoItem { async upload(targetPath: string): Promise { this.uploadStatus = UploadStatus.Uploading; let path = targetPath + FormatTime(this.dateTaken, DEFAULT_FORMAT); - let mkdirResult = await CreatePath(path); + let mkdirResult = await CreatePath(this.account, path); if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { // 405 is the status code for "already exists" Logger.Error(TAG, `Failed to create path: ${path}`); @@ -82,7 +87,7 @@ export class PhotoItem { let ret: UploadResult = UploadResult.FAILED; let message = ""; - let uploadResult = await UploadFile(this.displayName, this.dateTakenMs, path, this.uri); + let uploadResult = await UploadFile(this.account, this.displayName, this.dateTakenMs, path, this.uri); if (uploadResult !== 201 && uploadResult !== 204) { message = `Failed: ${this.displayName}: ${uploadResult}`; this.uploadStatus = UploadStatus.Failed; diff --git a/entry/src/main/ets/pages/FolderPage.ets b/entry/src/main/ets/pages/FolderPage.ets index 29b943e..b5e7332 100644 --- a/entry/src/main/ets/pages/FolderPage.ets +++ b/entry/src/main/ets/pages/FolderPage.ets @@ -1,7 +1,7 @@ -import { DirectoryEntry, FilesDataSource, Logger, NCommon } from 'nextcloud'; +import { Account, DirectoryEntry, FilesDataSource, Logger, NCommon } from 'nextcloud'; import { Path } from '../utils/Path'; import { PREVIEW_PAGE_NAME, PreviewPageProps } from './PreviewPage'; -import { LengthMetrics, promptAction } from '@kit.ArkUI'; +import { LengthMetrics } from '@kit.ArkUI'; import { FormatBytes } from '../utils/Utils'; import { Constants, FolderViewMode } from '../utils/Common'; @@ -11,6 +11,7 @@ export let FOLDER_PAGE_NAME: string = "FolderPage"; @Component export struct FolderPage { + @ObjectLink account: Account; @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; @Consume('FolderList.PathStack') pageInfos: NavPathStack; @Prop currentPath: Path; @@ -24,9 +25,17 @@ export struct FolderPage { let pathInfo: NavPathInfo; Logger.Info(TAG, JSON.stringify(entry)); if (entry.isDirectory) { - pathInfo = new NavPathInfo(FOLDER_PAGE_NAME, this.currentPath.getChild(entry.name)); + let props: FolderPageProps = { + account: this.account, + currentPath: this.currentPath.getChild(entry.name), + }; + pathInfo = new NavPathInfo(FOLDER_PAGE_NAME, props); } else if (entry.has_preview) { - let props: PreviewPageProps = { entry: entry, fullPath: this.currentPath.getChild(entry.name) }; + let props: PreviewPageProps = { + account: this.account, + entry: entry, + fullPath: this.currentPath.getChild(entry.name), + }; pathInfo = new NavPathInfo(PREVIEW_PAGE_NAME, props); } else { try { @@ -95,12 +104,12 @@ export struct FolderPage { build() { NavDestination() { List({ space: 5 }) { - LazyForEach(new FilesDataSource(this.currentPath.path()), (d: DirectoryEntry) => { + LazyForEach(new FilesDataSource(this.account, this.currentPath.path()), (d: DirectoryEntry) => { ListItem() { Button({ type: ButtonType.Normal }) { Row() { if (d.has_preview) { - Image(NCommon.MakePreviewUrl(d.fileId)) + Image(this.account.getPreviewUrl(d.fileId)) .width(32) .height(32) .margin({ right: 10 }) @@ -128,7 +137,7 @@ export struct FolderPage { .width(this.viewMode == 1 ? '75%' : '70%') } } - .backgroundColor('#00000000') + .backgroundColor(Color.Transparent) .borderRadius(0) .onClick(async () => { await this.onItemClicked(d); @@ -151,7 +160,12 @@ export struct FolderPage { } } +export interface FolderPageProps { + account: Account; + currentPath: Path; +} + @Builder -export function FolderPageBuilder(_name: string, currentPath: Path) { - FolderPage({ currentPath }); +export function FolderPageBuilder(_name: string, props: FolderPageProps) { + FolderPage({ account: props.account, currentPath: props.currentPath }); } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 61a250c..2ac62d1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1,4 +1,4 @@ -import { Logger, NCommon } from 'nextcloud'; +import { Account, Logger, NCommon, Nextcloud } from 'nextcloud'; import { App, promptAction } from '@kit.ArkUI'; import { Path } from '../utils/Path'; import { UploadPage } from './UploadPage'; @@ -7,8 +7,10 @@ import { SettingsPage } from './SettingsPage'; import { FolderPage } from './FolderPage'; import { Constants, FolderViewMode } from '../utils/Common'; import { PermissionManager } from '../utils/PermissionManager'; -import { UploadScheduler } from '../utils/UploadScheduler'; +import { PhotoAssetsManager } from '../utils/PhotoAssetsManager'; import { PhotoMonitor } from '../utils/PhotoMonitor'; +import { ShowToast } from '../utils/Utils'; +import { Permissions } from '@kit.AbilityKit'; const TAG = "Page-Index"; @@ -20,6 +22,8 @@ struct Index { @State currentIndex: number = 0; @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; @Provide('FolderList.PathStack') pathStack: NavPathStack = new NavPathStack(); + @StorageLink(Constants.PHONE_ACTIVE_ACCOUNT_ID) private readonly activeAccountId: number = 0; + @State private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); @State private loginPageDialogId: number = 0; private backedOnce: boolean = false; @@ -41,39 +45,35 @@ struct Index { this.backedOnce = false; }, EXIT_TOAST_DURATION); - try { - this.getUIContext().getPromptAction().showToast({ - message: $r('app.string.swipe_back_again_to_exit'), - duration: EXIT_TOAST_DURATION, - }); - } catch (error) { - Logger.Error(TAG, `Failed to show toast?`); - } + ShowToast(this.getUIContext(), $r('app.string.swipe_back_again_to_exit'), EXIT_TOAST_DURATION); return true; } } async aboutToAppear() { Logger.Info(TAG, `aboutToAppear`); - let context = this.getUIContext().getHostContext()!; - let result1 = await PermissionManager.RequestPermission(context, 'ohos.permission.READ_IMAGEVIDEO'); - Logger.Info(TAG, `Request permission result: ${result1}`); - let result2 = await PermissionManager.RequestPermission(context, 'ohos.permission.MEDIA_LOCATION'); - Logger.Info(TAG, `Request permission result: ${result2}`); - UploadScheduler.initialize(); + let context = this.getUIContext(); + const PERMISSIONS: Permissions[] = [ + 'ohos.permission.READ_IMAGEVIDEO', + 'ohos.permission.MEDIA_LOCATION', + ]; + let result = await PermissionManager.RequestPermission(context, PERMISSIONS); + Logger.Info(TAG, `Request permission result: ${result}`); + PhotoAssetsManager.initialize(NCommon.Preference.categories); PhotoMonitor.beginMonitorPhotoChange(); } async doLogin() { + let context = this.getUIContext(); + let options: promptAction.CustomDialogOptions = { + builder: () => { + return this.LoginPageDialog(); + }, + isModal: true, + autoCancel: false, + }; + try { - let context = this.getUIContext(); - let options: promptAction.CustomDialogOptions = { - builder: () => { - this.LoginPageDialog() - }, - isModal: true, - autoCancel: false, - }; this.loginPageDialogId = await context.getPromptAction().openCustomDialog(options); Logger.Info(TAG, `done`); } catch (error) { @@ -86,7 +86,7 @@ struct Index { App.setImageRawDataCacheSize(1024 * 1024 * 100); // 100MB App.setImageFileCacheSize(1024 * 1024 * 500); // 100MB - if (!NCommon.Preference.hasLogon()) { + if (!Nextcloud.hasAccounts()) { this.doLogin(); } } @@ -96,7 +96,7 @@ struct Index { TabContent() { Flex({ direction: FlexDirection.Column }) { Navigation(this.pathStack) { - FolderPage({ currentPath: new Path() }) + FolderPage({ account: this.account, currentPath: new Path() }) } .navBarWidth(0) .mode(NavigationMode.Stack) @@ -126,7 +126,7 @@ struct Index { .tabBar($r('app.string.tab_upload')) TabContent() { - SettingsPage() + SettingsPage({ currentAccount: this.account }) .width('100%') .height('100%') } diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index 677e97d..5495502 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -1,63 +1,51 @@ -import { LoginProcess, NCommon, Logger } from 'nextcloud'; +import { Logger, LoginFlow } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; import { webview } from '@kit.ArkWeb'; +import { ShowAlertDialog, ShowToast } from '../utils/Utils'; -const TAG = 'pages/LoginPage' +const TAG = 'Pages/LoginPage'; -@Preview @Component export struct LoginPage { @Link public dialogId: number; private webController: webview.WebviewController = new webview.WebviewController(); - private loginProcess = new LoginProcess(); + private loginFlow = new LoginFlow(); + @State private hostname: string = ''; @State private loginStarted: boolean = false; - async doLogin() { - if (!this.loginProcess.hasAppPassword()) { - Logger.Debug(TAG, "No app password, start login process"); - try { - await webview.WebCookieManager.clearAllCookies(); - let initResult = await this.loginProcess.initLogin(); - if (!initResult.success) { - Logger.Error(TAG, `Login failed: ${initResult.errorMsg}`); - this.getUIContext().showAlertDialog({ - title: "Login Failed", - message: `Login failed with domain: '${NCommon.Preference.hostname}', ${initResult.errorMsg}`, - }); - return; - } + async doLogin(): Promise { + await webview.WebCookieManager.clearAllCookies(); + let initResult = await this.loginFlow.initLogin(this.hostname); + if (initResult.kind === 'error') { + Logger.Error(TAG, `Login failed: ${initResult.error}`); + ShowAlertDialog(this.getUIContext(), "Login Failed", `Login failed with domain: '${this.hostname}', ${initResult.error}`); + return; + } - this.loginStarted = true; - this.webController.loadUrl(initResult.loginUrl); - Logger.Info(TAG, "Successfully opened login url"); - - let result = await this.loginProcess.loginPollRepeatedly(); - Logger.Info(TAG, "Login result: " + JSON.stringify(result)); - - if (result.cancelled) { - this.getUIContext().showAlertDialog({ - title: "Login Failed", - message: "Operation Cancelled", - }); - } else { - this.getUIContext().showAlertDialog({ - title: "Welcome to use NextCloud", - message: "Welcome, " + result.loginName, - }); - this.closeDialog(); - } - } catch (e) { - this.getUIContext().showAlertDialog({ - title: "Login failed", - message: JSON.stringify(e), - }) + this.startUserLoginProcess(initResult.data); + + let loginResult = await this.loginFlow.loginPollRepeatedly(); + switch (loginResult.kind) { + case 'ok': { + ShowAlertDialog(this.getUIContext(), "Login Successful", `Welcome, ${loginResult.account.username}!`); + this.closeDialog(); + break; + } + case 'cancelled': { + ShowAlertDialog(this.getUIContext(), "Login Failed", "Operation Cancelled"); + break; } - } else { - Logger.Info(TAG, "Already have app password, skip login process"); - this.getUIContext().showAlertDialog({ - title: "Welcome", - message: NCommon.Preference.username, - }); + } + } + + startUserLoginProcess(loginUrl: string) { + try { + this.webController.loadUrl(loginUrl); + Logger.Info(TAG, "Successfully opened login url"); + this.loginStarted = true; + } catch (error) { + ShowAlertDialog(this.getUIContext(), "Login Failed", `Failed to open login url: ${JSON.stringify(error)}`); + Logger.Error(TAG, `Failed to open login url: ${JSON.stringify(error)}`); } } @@ -79,13 +67,9 @@ export struct LoginPage { .backgroundColor(Color.Red) .height(32) .onClick(() => { - this.loginProcess.cancelPolling(); + this.loginFlow.cancelPolling(); this.loginStarted = false; - NCommon.Preference.password = ""; - this.getUIContext().getPromptAction().showToast({ - message: "Logged out", - duration: 2000, - }); + ShowToast(this.getUIContext(), "Cancelled", 2000); }) .alignRules({ left: { anchor: '__container__', align: HorizontalAlign.Start }, @@ -124,17 +108,9 @@ export struct LoginPage { if (!this.loginStarted) { Text('Type the URL of your Nextcloud instance:') .margin({ left: 24, right: 24, bottom: 8 }) - Flex({ - alignItems: ItemAlign.Center, - direction: FlexDirection.Row, - space: { - main: LengthMetrics.vp(8) - } - }) { - TextInput({ placeholder: 'https://nextcloud.com', text: NCommon.Preference.hostname }) - .onChange((value: string) => { - NCommon.Preference.hostname = value; - }) + + Flex({ alignItems: ItemAlign.Center, direction: FlexDirection.Row, space: { main: LengthMetrics.vp(8) } }) { + TextInput({ placeholder: 'https://nextcloud.com', text: this.hostname!! }) .flexGrow(1) Button({ type: ButtonType.Circle }) { @@ -167,3 +143,16 @@ export struct LoginPage { this.getUIContext().getPromptAction().closeCustomDialog(this.dialogId); } } + + +@Preview +@Component +struct LoginPagePreview { + @State private dialogId: number = 0; + + build() { + LoginPage({ dialogId: this.dialogId }) + .width(300) + .height(500) + } +} diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index ac923db..7ae35fa 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -1,4 +1,4 @@ -import { DirectoryEntry, ListFiles, Logger, DownloadFile } from 'nextcloud'; +import { DirectoryEntry, ListFiles, Logger, DownloadFile, Nextcloud, Account } from 'nextcloud'; import { common } from '@kit.AbilityKit'; import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; import { Constants } from '../utils/Common'; @@ -41,6 +41,8 @@ struct PCAbility { @StorageLink(Constants.PC_CLOUD_DISK_ALIAS) @Watch('onAliasChanged') private alias: string = 'Moody 的 Nextcloud'; @State private opHistory: CloudSyncApi.FileChangeInfo[] = []; @State private files: DirectoryEntry[] = []; + @StorageLink(Constants.PHONE_ACTIVE_ACCOUNT_ID) private readonly activeAccountId: number = 0; + @State private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); @State private transferManager: TransferManagerImpl = TransferManager; private downloadQueue: DownloadQueue = new DownloadQueue(); private pendingMoveTo: Map = new Map(); @@ -51,11 +53,7 @@ struct PCAbility { async aboutToAppear(): Promise { Logger.Info(TAG, `pcAbility aboutToAppear`); - await PermissionManager.RequestPermission( - this.getUIContext()! - .getHostContext()!, - "ohos.permission.FILE_ACCESS_PERSIST" - ) + await PermissionManager.RequestPermission(this.getUIContext(), ["ohos.permission.FILE_ACCESS_PERSIST"]) if (this.path) { CloudSyncApi.connect(this.path); } @@ -84,7 +82,7 @@ struct PCAbility { } catch (e) { Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); } - this.files = await ListFiles('/'); + this.files = await ListFiles(this.account, '/'); } aboutToDisappear(): void { @@ -166,13 +164,13 @@ struct PCAbility { Button('reload') .onClick(async () => { - this.files = await ListFiles('/'); + this.files = await ListFiles(this.account, '/'); }) Button('sync all') .onClick(async () => { Logger.Info(TAG, `sync all`); - this.downloadQueue.addRootDir(); + this.downloadQueue.addRootDir(this.account); while (this.downloadQueue.hasPendingItem()) { let next = await this.downloadQueue.getNextDownloadFile(); if (next === undefined) { @@ -185,7 +183,7 @@ struct PCAbility { } catch (e) { } } else { - await DownloadFile(next.relativePath, this.path + '/' + next.relativePath); + await DownloadFile(this.account, next.relativePath, this.path + '/' + next.relativePath); } Logger.Info(TAG, next.relativePath); } diff --git a/entry/src/main/ets/pages/PreviewPage.ets b/entry/src/main/ets/pages/PreviewPage.ets index c965bc7..88cfb0d 100644 --- a/entry/src/main/ets/pages/PreviewPage.ets +++ b/entry/src/main/ets/pages/PreviewPage.ets @@ -1,16 +1,18 @@ -import { DirectoryEntry, Logger, NCommon } from 'nextcloud'; +import { Account, DirectoryEntry, Logger } from 'nextcloud'; import { Path } from '../utils/Path'; const TAG: string = "Page-PreviewPage"; export let PREVIEW_PAGE_NAME: string = "PreviewPage"; + @Component struct PreviewPage { + @ObjectLink account: Account; @Prop entry: DirectoryEntry; @Prop fullPath: Path = new Path(); @State private previewLoaded: boolean = false; - @State private previewLink: string = NCommon.MakePreviewUrl(this.entry.fileId, 100, 100); + @State private previewLink: string = this.account.getPreviewUrl(this.entry.fileId, 100, 100); @State @Watch('onPreviewLevelChanged') private previewLevel: number = 0; @State private failedToLoad: boolean = false; @State private failedReason: string = ""; @@ -48,9 +50,9 @@ struct PreviewPage { if (this.previewLevel == 2) { Logger.Info(TAG, "Preview image loaded, start loading full image"); if (this.entry.mime.startsWith("image/")) { - this.previewLink = NCommon.MakeDownloadUrl(this.fullPath.path(false)); + this.previewLink = this.account.getDownloadUrl(this.fullPath.path(false)); } else { - this.previewLink = NCommon.MakePreviewUrl(this.entry.fileId, 1000, 1000); + this.previewLink = this.account.getPreviewUrl(this.entry.fileId, 1000, 1000); } } }) @@ -126,9 +128,10 @@ struct PreviewPage { export interface PreviewPageProps { entry: DirectoryEntry; fullPath: Path; + account: Account; } @Builder export function PreviewPageBuilder(_name: string, props: PreviewPageProps) { - PreviewPage(props); + PreviewPage({ account: props.account, entry: props.entry, fullPath: props.fullPath }); } diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index f029467..7388a89 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -1,4 +1,4 @@ -import { Logger, NCommon } from 'nextcloud'; +import { Logger, NCommon, Account } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; const TAG = 'pages/Settings'; @@ -19,6 +19,7 @@ class EditableCategory implements NCommon.CategoryConfig { @Component export struct SettingsPage { scroller: Scroller = new Scroller(); + @ObjectLink public currentAccount: Account; @State private username: string = ""; @State private hostname: string = ""; @State private uploadReplaceExisting: boolean = false; @@ -158,8 +159,8 @@ export struct SettingsPage { private doLoadSettings() { if (NCommon.Preference) { - this.username = NCommon.Preference.username; - this.hostname = NCommon.Preference.hostname; + this.username = this.currentAccount.username; + this.hostname = this.currentAccount.hostname; this.uploadReplaceExisting = NCommon.Preference.replaceExistingFiles; this.uploadBaseDirectory = NCommon.Preference.baseUploadDirectory; this.uploadCategories = []; @@ -173,8 +174,6 @@ export struct SettingsPage { private doSaveSettings() { if (NCommon.Preference) { - NCommon.Preference.username = this.username; - NCommon.Preference.hostname = this.hostname; NCommon.Preference.replaceExistingFiles = this.uploadReplaceExisting; NCommon.Preference.baseUploadDirectory = this.uploadBaseDirectory; NCommon.Preference.categories = this.uploadCategories; diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 6f6b80a..497727c 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -2,7 +2,7 @@ import { Logger, NCommon } from 'nextcloud'; import { Category } from '../models/Category'; import { LengthMetrics } from '@kit.ArkUI'; import { PhotoItem, UploadStatus } from '../models/PhotoItem'; -import { UploadScheduler } from '../utils/UploadScheduler'; +import { PhotoAssetsManager } from '../utils/PhotoAssetsManager'; const TAG = "Page-UploadPage" @@ -106,7 +106,7 @@ struct PhotosGridView { @Preview @Component export struct UploadPage { - @State private categories: Category[] = UploadScheduler.categories; + @State private categories: Category[] = PhotoAssetsManager.categories; build() { Tabs() { diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index e42bd5c..079fc44 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -7,6 +7,8 @@ export enum FolderViewMode { export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; + export const PHONE_ACTIVE_ACCOUNT_ID = 'Phone.ActiveAccountId'; + export const PC_CLOUD_DISK_PATH = 'PC.CloudDisk.Path'; export const PC_CLOUD_DISK_ALIAS = 'PC.CloudDisk.Alias'; diff --git a/entry/src/main/ets/utils/DownloadQueue.ets b/entry/src/main/ets/utils/DownloadQueue.ets index 6b946ec..c5a1bd4 100644 --- a/entry/src/main/ets/utils/DownloadQueue.ets +++ b/entry/src/main/ets/utils/DownloadQueue.ets @@ -1,20 +1,22 @@ -import { DirectoryEntry, ListFiles, Logger } from "nextcloud"; +import { Account, DirectoryEntry, ListFiles, Logger } from "nextcloud"; const DOWNLOAD_DEPTH_FIRST: boolean = true; const TAG = 'Utils/DownloadQueue'; export class DownloadItem { + account: Account; relativePath: string; isDirectory: boolean; - constructor(relativePath: string, isDirectory: boolean) { + constructor(account: Account, relativePath: string, isDirectory: boolean) { + this.account = account; this.relativePath = relativePath; this.isDirectory = isDirectory; } public makeChildItem(filename: string, isDirectory: boolean): DownloadItem { let relativePath = this.relativePath.endsWith('/') ? this.relativePath : this.relativePath + '/'; - return new DownloadItem(relativePath + filename, isDirectory); + return new DownloadItem(this.account, relativePath + filename, isDirectory); } } @@ -22,6 +24,26 @@ export class DownloadItem { export class DownloadQueue { queue: DownloadItem[] = []; + public hasPendingItem() { + return this.queue.length > 0; + } + + public async getNextDownloadFile(): Promise { + do { + let next = await this.shiftNext(); + if (next !== undefined) { + return next; + } + } while (this.hasPendingItem()); + Logger.Info(TAG, `download queue empty`); + return undefined; + } + + public addRootDir(account: Account) { + let rootItem = new DownloadItem(account, '/', true); + this.queue.push(rootItem); + } + private async shiftNext(): Promise { let next = this.queue.shift(); if (!next) { @@ -31,7 +53,7 @@ export class DownloadQueue { if (next.isDirectory) { Logger.Debug(TAG, `downloading dir: ${next.relativePath}`); - let items = await ListFiles(next.relativePath); + let items = await ListFiles(next.account, next.relativePath); let downloadItems = items.map((item: DirectoryEntry): DownloadItem => { Logger.Info(TAG, `${item.name}`); return next!.makeChildItem(item.name, item.isDirectory); @@ -47,24 +69,4 @@ export class DownloadQueue { } return next; } - - public hasPendingItem() { - return this.queue.length > 0; - } - - public async getNextDownloadFile(): Promise { - do { - let next = await this.shiftNext(); - if (next !== undefined) { - return next; - } - } while (this.hasPendingItem()); - Logger.Info(TAG, `download queue empty`); - return undefined; - } - - public addRootDir() { - let rootItem = new DownloadItem('/', true); - this.queue.push(rootItem); - } } diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets index f3b9676..b09ab47 100644 --- a/entry/src/main/ets/utils/NotificationManager.ets +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -4,75 +4,74 @@ import { Logger } from 'nextcloud'; import { BUNDLE_NAME } from 'BuildProfile'; import { BusinessError } from '@ohos.base'; import { notificationManager } from '@kit.NotificationKit'; -import { UploadScheduler } from './UploadScheduler'; +import { PhotoAssetsManager } from './PhotoAssetsManager'; const TAG = "Util-NotificationManager"; -export namespace Notification { - const WANT_AGENT_INFO: wantAgent.WantAgentInfo = { +class NotificationImpl { + private readonly WANT_AGENT_INFO: wantAgent.WantAgentInfo = { wants: [{ bundleName: BUNDLE_NAME, abilityName: "EntryAbility" }], actionType: wantAgent.OperationType.START_ABILITY, requestCode: 0, wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] }; - const BACKGROUND_JOB_TYPE: Array = ["dataTransfer"]; - let notificationId: Optional = undefined; - let refreshNotificationTimer: number = 0; + private readonly BACKGROUND_JOB_TYPE: Array = ["dataTransfer"]; + private notificationId: Optional = undefined; + private refreshNotificationTimer: number = 0; - - export async function startNotification(context: Context) { + public async startNotification(context: Context) { try { - let wantAgentObj = await wantAgent.getWantAgent(WANT_AGENT_INFO); - let res = await backgroundTaskManager.startBackgroundRunning(context, BACKGROUND_JOB_TYPE, wantAgentObj); - notificationId = res.notificationId; - await publishIdleNotification(); - Logger.Info(TAG, `Operation startBackgroundRunning succeeded: ${notificationId}`); + let wantAgentObj = await wantAgent.getWantAgent(this.WANT_AGENT_INFO); + let res = await backgroundTaskManager.startBackgroundRunning(context, this.BACKGROUND_JOB_TYPE, wantAgentObj); + this.notificationId = res.notificationId; + await this.publishIdleNotification(); + Logger.Info(TAG, `Operation startBackgroundRunning succeeded: ${this.notificationId}`); } catch (error) { let e = error as BusinessError; Logger.Error(TAG, `Operation startBackgroundRunning failed. code is ${e.code} message is ${e.message}`); } } - export async function publishUploadProgress(currentNumber: number, totalNumber: number) { - if (notificationId === undefined) { + public async publishUploadProgress(currentNumber: number, totalNumber: number) { + if (this.notificationId === undefined) { Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); return; } let process = Math.floor((currentNumber / totalNumber) * 100); - await publishUploadProgressInner('Nextcloud Upload', `Uploading ${currentNumber} of ${totalNumber}`, process); + await this.publishUploadProgressInner('Nextcloud Upload', `Uploading ${currentNumber} of ${totalNumber}`, process); } - export async function publishIdleNotification() { - if (notificationId === undefined) { + public async publishIdleNotification() { + if (this.notificationId === undefined) { Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); return; } let randomProgress = Math.floor(Math.random() * 100); - await UploadScheduler.doFullUpload(false); - await publishUploadProgressInner('Nextcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, randomProgress); + await this.publishUploadProgressInner('Nextcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, randomProgress); - clearInterval(refreshNotificationTimer); - refreshNotificationTimer = setInterval(async () => { - await publishIdleNotification(); + clearInterval(this.refreshNotificationTimer); + this.refreshNotificationTimer = setInterval(async () => { + await this.publishIdleNotification(); + await PhotoAssetsManager.doFullUpload(false); // false = do not notify again }, 3 * 30 * 1000); } - export async function publishPendingNotification(due: Date, percent: number) { - if (notificationId === undefined) { + public async publishPendingNotification(due: Date, percent: number) { + if (this.notificationId === undefined) { Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); return; } - await publishUploadProgressInner('Nextcloud Pending Upload', `Scheduled at ${due.toLocaleTimeString()}`, percent); + await this.publishUploadProgressInner('Nextcloud Pending Upload', `Scheduled at ${due.toLocaleTimeString()}`, percent); } - async function publishUploadProgressInner(title: string, subtitle: string, progress: number) { - clearInterval(refreshNotificationTimer); - await doPublishUploadProgress(title, subtitle, progress); + private async publishUploadProgressInner(title: string, subtitle: string, progress: number) { + clearInterval(this.refreshNotificationTimer); + await this.doPublishUploadProgress(title, subtitle, progress); } - async function doPublishUploadProgress(title: string, subtitle: string, progress: number) { - Logger.Info(TAG, `publishUploadProgress: title='${title}', subtitle='${subtitle}', progress=${progress} to ${notificationId}`); + private async doPublishUploadProgress(title: string, subtitle: string, progress: number) { + Logger.Info(TAG, `publishUploadProgress: title='${title}', subtitle='${subtitle}', progress=${progress} to ${this.notificationId}`); let downloadTemplate: notificationManager.NotificationTemplate = { name: 'downloadTemplate', data: { @@ -87,7 +86,7 @@ export namespace Notification { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_SYSTEM_LIVE_VIEW, systemLiveView: { typeCode: 8, title: '', text: '', } }, - id: notificationId, + id: this.notificationId, notificationSlotType: notificationManager.SlotType.LIVE_VIEW, template: downloadTemplate, }; @@ -99,3 +98,5 @@ export namespace Notification { } } } + +export const Notification = new NotificationImpl(); diff --git a/entry/src/main/ets/utils/PermissionManager.ets b/entry/src/main/ets/utils/PermissionManager.ets index d79c043..9734dea 100644 --- a/entry/src/main/ets/utils/PermissionManager.ets +++ b/entry/src/main/ets/utils/PermissionManager.ets @@ -1,47 +1,70 @@ import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; -import { BusinessError } from '@kit.BasicServicesKit'; import { Logger } from 'nextcloud'; +import { UIContext } from '@ohos.arkui.UIContext'; const TAG = 'utils/PermissionManager'; export class PermissionManager { - static async RequestPermission(context: Context, permission: Permissions): Promise { + static async RequestPermission(context: UIContext, perms: Permissions[]): Promise { let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); + Logger.Warn(TAG, `requestPermission for permissions: ${perms}`); - if (await PermissionManager.verifyPermission(permission)) { - Logger.Info(TAG, `requestPermission success, permissionName->${permission}`); + let toGrant = await PermissionManager.getPermissionsToGrant(atManager, perms); + if (toGrant.length === 0) { + Logger.Info(TAG, `requestPermission success, all permissions already granted`); return true; } - let result = await atManager.requestPermissionsFromUser(context, [permission]); + let result = await atManager.requestPermissionsFromUser(context.getHostContext()!, toGrant); + let leftoverPermissions: Permissions[] = []; for (let i = 0; i < result.permissions.length; i++) { let rPerm = result.permissions[i]; let rResult = result.authResults[i]; - - if (rPerm == permission && rResult == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { - Logger.Info(TAG, `requestPermission success, permissionName->${permission}`); - return true; + let isGranted = rResult == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + if (!isGranted) { + leftoverPermissions.push(rPerm as Permissions); } } - Logger.Warn(TAG, `requestPermission fail, ${permission}, requesting to open settings`); - await AlertDialog.show({ message: '请开启权限' }); - await atManager.requestPermissionOnSetting(context, [permission]); + if (leftoverPermissions.length == 0) { + Logger.Info(TAG, `requestPermission success: ${toGrant}`); + return true; + } + + Logger.Warn(TAG, `requestPermission fail, ${leftoverPermissions}, requesting to open settings`); + context.showAlertDialog({ message: '需要开启权限才能使用全部功能' }); + await atManager.requestPermissionOnSetting(context.getHostContext()!, leftoverPermissions); return false; } - private static async verifyPermission(permission: Permissions): Promise { - let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); - let tokenID: number = (await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)) - .appInfo - .accessTokenId; - let result: boolean = false; - await atManager.verifyAccessToken(tokenID, permission).then((data: abilityAccessCtrl.GrantStatus) => { - result = data == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; - }).catch((err: BusinessError) => { - Logger.Error(TAG, `verifyPermission fail, err->${JSON.stringify(err)}`); - }); - return result; + /** + * Verify if the permission is granted + * @param permission + * @returns Promise permissions that are NOT granted + */ + private static async getPermissionsToGrant(atManager: abilityAccessCtrl.AtManager, permission: Permissions[]): Promise { + let bundleInfo = await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + + let verifySinglePermission = async (perm: Permissions): Promise => { + try { + let grantStatus = await atManager.verifyAccessToken(bundleInfo.appInfo.accessTokenId, perm); + Logger.Debug(TAG, `verifyPermission for ${perm}, grantStatus: ${grantStatus}`); + return grantStatus == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + } catch (error) { + Logger.Error(TAG, `verifyPermission exception for ${perm}, err->${JSON.stringify(error)}`); + return false; + } + }; + + let notGrantedPermissions: Permissions[] = []; + for (let perm of permission) { + if (!await verifySinglePermission(perm)) { + notGrantedPermissions.push(perm); + } + } + + Logger.Info(TAG, `verifyPermission, not granted permissions: ${notGrantedPermissions}`); + return notGrantedPermissions; } } diff --git a/entry/src/main/ets/utils/UploadScheduler.ets b/entry/src/main/ets/utils/PhotoAssetsManager.ets similarity index 69% rename from entry/src/main/ets/utils/UploadScheduler.ets rename to entry/src/main/ets/utils/PhotoAssetsManager.ets index 8d5fdbe..9b2c07f 100644 --- a/entry/src/main/ets/utils/UploadScheduler.ets +++ b/entry/src/main/ets/utils/PhotoAssetsManager.ets @@ -1,4 +1,4 @@ -import { Logger, NCommon } from 'nextcloud'; +import { AsyncSleep, Logger, NCommon } from 'nextcloud'; import { AddAssetResult, AddPosition, Category } from '../models/Category'; import { PhotoItem } from '../models/PhotoItem'; import { GlobalStorage } from './GlobalStorage'; @@ -6,12 +6,10 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { dataSharePredicates } from '@kit.ArkData'; import { Notification } from './NotificationManager'; -const TAG = 'Utils-UploadScheduler'; +const TAG = 'Utils/PhotoAssetsManager'; -export namespace UploadScheduler { - export let categories: Category[] = []; - - const FETCH_COLUMNS = [ +class PhotoAssetsManagerImpl { + private static readonly FETCH_COLUMNS = [ photoAccessHelper.PhotoKeys.URI, photoAccessHelper.PhotoKeys.PHOTO_TYPE, photoAccessHelper.PhotoKeys.PHOTO_SUBTYPE, @@ -27,35 +25,23 @@ export namespace UploadScheduler { photoAccessHelper.PhotoKeys.DETAIL_TIME, photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, ]; + public categories: Category[] = []; - - function doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { - for (let category of categories) { - if (category.tryAddAsset(photoItem, addPosition) != AddAssetResult.Unmatched) { - break; - } - } - } - - - export async function initialize() { - while (categories.length > 0) { - categories.pop(); - } - - for (let c of NCommon.Preference.categories) { - categories.push(new Category(c)); + public async initialize(categories: NCommon.CategoryConfig[]) { + this.categories = []; + for (let c of categories) { + this.categories.push(new Category(c)); } const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { - fetchColumns: FETCH_COLUMNS, + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, predicates: new dataSharePredicates.DataSharePredicates().orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN), }; try { let result = await GlobalStorage.pHelper.getAssets(FETCH_OPTIONS); for (let asset of await result.getAllObjects()) { - doAddPhotoItem(new PhotoItem(asset), AddPosition.Append); + this.doAddPhotoItem(new PhotoItem(asset), AddPosition.Append); } result.close(); } catch (error) { @@ -63,11 +49,10 @@ export namespace UploadScheduler { } } - - export async function prependNewAsset(assetUri: string) { - Logger.Info(TAG, `addNewAsset enter, uri: ${assetUri}`); + public async prependNewAsset(assetUri: string) { + Logger.Info(TAG, `prependNewAsset enter, uri: ${assetUri}`); const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { - fetchColumns: FETCH_COLUMNS, + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, predicates: new dataSharePredicates.DataSharePredicates().equalTo(photoAccessHelper.PhotoKeys.URI, assetUri), }; @@ -79,21 +64,31 @@ export namespace UploadScheduler { } let photoItem = new PhotoItem(await assetResult.getFirstObject()); Logger.Info(TAG, `prepending new asset: ${photoItem.displayName}, uri: ${photoItem.uri}`); - doAddPhotoItem(photoItem, AddPosition.Prepend); + this.doAddPhotoItem(photoItem, AddPosition.Prepend); assetResult.close(); } catch (error) { Logger.Error(TAG, `failed to add new asset: ${error}`); } } - export async function doFullUpload(notify: boolean = true) { - for (let category of UploadScheduler.categories) { + public async doFullUpload(notify: boolean = true) { + for (let category of this.categories) { await category.uploadAllAssets(notify); } - await new Promise((resolve) => setTimeout(resolve, 5000)); // wait for 1 second to ensure all uploads are started + await AsyncSleep(5000); // wait for 5s to ensure all uploads are started if (notify) { await Notification.publishIdleNotification(); } } + + private doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { + for (let category of this.categories) { + if (category.tryAddAsset(photoItem, addPosition) != AddAssetResult.Unmatched) { + break; + } + } + } } + +export const PhotoAssetsManager = new PhotoAssetsManagerImpl(); diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets index 9ae2784..5b5d653 100644 --- a/entry/src/main/ets/utils/PhotoMonitor.ets +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -2,71 +2,76 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { Logger } from 'nextcloud'; import { GlobalStorage } from './GlobalStorage'; import { Notification } from './NotificationManager'; -import { UploadScheduler } from './UploadScheduler'; +import { PhotoAssetsManager } from './PhotoAssetsManager'; const TAG = 'Utils-PhotoMonitor'; -export namespace PhotoMonitor { - let doUploadTimer: number | undefined = undefined; - const UPLOAD_DELAY_MS = 30 * 1000; // wait for this time before triggering full upload +class PhotoMonitorImpl { + private static readonly FULL_UPLOAD_INTERVAL_MS = 60 * 60 * 1000; // perform full upload at least every hour + private static readonly UPLOAD_DELAY_MS = 30 * 1000; // wait for this time before triggering full upload + private doUploadTimer: number | undefined = undefined; + private fullUploadTimer: number | undefined = undefined; + private readonly onPhotoChangedCallback = async (changeData: photoAccessHelper.ChangeData): Promise => { + await this.doOnPhotoChanged(changeData); + }; - let fullUploadTimer: number | undefined = undefined; - const FULL_UPLOAD_INTERVAL_MS = 60 * 60 * 1000; // perform full upload at least every hour + public beginMonitorPhotoChange() { + try { + GlobalStorage.pHelper.registerChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, true, this.onPhotoChangedCallback); + } catch (error) { + Logger.Error(TAG, `Failed to register photo change monitor: ${error}`); + } + + this.scheduleFullUpload(); + } + + public stopMonitorPhotoChange() { + try { + GlobalStorage.pHelper.unRegisterChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, this.onPhotoChangedCallback); + } catch (error) { + Logger.Error(TAG, `Failed to unregister photo change monitor: ${error}`); + } - function scheduleFullUpload() { - clearInterval(fullUploadTimer); + } + + private async scheduleFullUpload() { + clearInterval(this.fullUploadTimer); - fullUploadTimer = setInterval(async () => { + this.fullUploadTimer = setInterval(async () => { Logger.Info(TAG, `>>> Scheduled full upload beginning`); - await UploadScheduler.doFullUpload(); - let nextTime = new Date(Date.now() + FULL_UPLOAD_INTERVAL_MS); + await PhotoAssetsManager.doFullUpload(); + let nextTime = new Date(Date.now() + PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); Logger.Info(TAG, `<<< Scheduled full upload completed, next time: ${nextTime}`); - }, FULL_UPLOAD_INTERVAL_MS); + }, PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); - let nextUploadTime = new Date(Date.now() + FULL_UPLOAD_INTERVAL_MS); + let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); Logger.Info(TAG, `+++ Scheduled full upload at ${nextUploadTime}`); } - async function doOnPhotoChanged(changeData: photoAccessHelper.ChangeData) { + private async doOnPhotoChanged(changeData: photoAccessHelper.ChangeData) { if (changeData.type !== photoAccessHelper.NotifyType.NOTIFY_ADD) { Logger.Debug(TAG, `change type isn't ADD, return directly`); return; } for (let uri of changeData.uris) { - await UploadScheduler.prependNewAsset(uri); + await PhotoAssetsManager.prependNewAsset(uri); } - clearTimeout(doUploadTimer); // cancel repeating upload timer - clearInterval(fullUploadTimer); // postpone full upload timer after photo change + clearTimeout(this.doUploadTimer); // cancel repeating upload timer + clearInterval(this.fullUploadTimer); // postpone full upload timer after photo change - doUploadTimer = setTimeout(async () => { + this.doUploadTimer = setTimeout(async () => { Logger.Info(TAG, `>>> Scheduled photo upload beginning`); - await UploadScheduler.doFullUpload(); + await PhotoAssetsManager.doFullUpload(); Logger.Info(TAG, `<<< Scheduled photo upload completed`); - scheduleFullUpload(); - }, UPLOAD_DELAY_MS); + this.scheduleFullUpload(); + }, PhotoMonitorImpl.UPLOAD_DELAY_MS); - let nextUploadTime = new Date(Date.now() + UPLOAD_DELAY_MS); + let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.UPLOAD_DELAY_MS); Notification.publishPendingNotification(nextUploadTime, 66); Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); } - - export function beginMonitorPhotoChange() { - try { - GlobalStorage.pHelper.registerChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, true, doOnPhotoChanged); - scheduleFullUpload(); - } catch (error) { - Logger.Error(TAG, `Failed to register photo change monitor: ${error}`); - } - } - - export function stopMonitorPhotoChange() { - try { - GlobalStorage.pHelper.unRegisterChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, doOnPhotoChanged); - } catch (error) { - Logger.Error(TAG, `Failed to unregister photo change monitor: ${error}`); - } - - } } + +export const PhotoMonitor = new PhotoMonitorImpl(); diff --git a/entry/src/main/ets/utils/Utils.ets b/entry/src/main/ets/utils/Utils.ets index 8381c39..c702d0a 100644 --- a/entry/src/main/ets/utils/Utils.ets +++ b/entry/src/main/ets/utils/Utils.ets @@ -1,3 +1,8 @@ +import { UIContext } from '@ohos.arkui.UIContext'; +import { Logger } from 'nextcloud'; + +const TAG = "Utils/Utils"; + export function FormatBytes(bytes: string) { let bytesNum = parseInt(bytes); let units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -25,3 +30,28 @@ export function FormatTime(date: Date, format: string) { result = result.replace('ss', date.getSeconds().toString().padStart(2, '0')); return result; } + +export function ShowToast(context: UIContext, message: string | Resource, duration: number = 1500): void { + try { + Logger.Info(TAG, `Showing toast: '${message}' for ${duration}ms`); + context.getPromptAction().showToast({ message, duration, }); + } catch (error) { + Logger.Error(TAG, `Failed to show toast: ${JSON.stringify(error)}`); + } +} + +export function ShowAlertDialog( + context: UIContext, + title: string | Resource, + message: string | Resource, +): void { + try { + Logger.Info(TAG, `Showing alert dialog: '${title}' - '${message}'`); + context.showAlertDialog({ + title: title, + message: message, + }); + } catch (error) { + Logger.Error(TAG, `Failed to show alert dialog: ${JSON.stringify(error)}`); + } +} diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index cacbbc1..c6771c9 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -1,13 +1,43 @@ import { DBAccount } from '../Database/Types/DBAccount'; +import { Base64Encode } from '../Utils/CommonUtils'; import { Logger } from '../Utils/Logger'; -const TAG = 'SyncEngine/Account'; - -export class Account { - dbAccount: DBAccount; +const TAG = 'Account/Account'; +export class Account extends DBAccount { constructor(dbAccount: DBAccount) { Logger.Info(TAG, `constructor: enter`); - this.dbAccount = dbAccount; + super(dbAccount.id, dbAccount.hostname, dbAccount.username, dbAccount.password); + } + + private get baseUrlWithCredentials(): string { + return `https://${this.username}:${this.password}@${this.hostname}`; + } + + public static createNewAccount(hostname: string, username: string, password: string): Account { + Logger.Info(TAG, `createNewAccount: enter, hostname=${hostname}, username=${username}`); + let dbAccount = new DBAccount(0, hostname, username, password); + return new Account(dbAccount); + } + + public isLoggedIn(): boolean { + return this.password !== ''; + } + + public makeAuthorizationHeader(): string { + return 'basic ' + Base64Encode(this.username + ":" + this.password); + } + + public remoteBaseDirUrl(): string { + return `https://${this.hostname}/remote.php/dav/files/${this.username}/`; + } + + public getPreviewUrl(fileId: string, x: number = 64, y: number = 64): string { + return this.baseUrlWithCredentials + `/core/preview?fileId=${fileId}&x=${x}&y=${y}&mimeFallback=true`; + } + + public getDownloadUrl(path: string): string { + let encodedPath = encodeURI(path); + return this.baseUrlWithCredentials + `/remote.php/dav/files/${this.username}/${encodedPath}`; } } diff --git a/nextcloud/src/main/ets/Account/Login.ets b/nextcloud/src/main/ets/Account/LoginFlow.ets similarity index 61% rename from nextcloud/src/main/ets/Account/Login.ets rename to nextcloud/src/main/ets/Account/LoginFlow.ets index 6db79ad..b7e3d27 100644 --- a/nextcloud/src/main/ets/Account/Login.ets +++ b/nextcloud/src/main/ets/Account/LoginFlow.ets @@ -1,34 +1,34 @@ import { Logger } from '../Utils/Logger'; import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from '../nextcloud-api-sdk'; -import { NCommon } from '../Common'; import { AxiosError } from '@ohos/axios'; import { AsyncSleep } from '../Utils/CommonUtils'; +import { Err, Ok, Result } from '../Utils/Result'; +import { Account } from './Account'; +import { NCommon } from '../Common'; const TAG = 'Account/Login'; -interface InitLoginResult { - success: boolean; - loginUrl?: string; - errorMsg?: string; -}; +interface PollLoginResultOk { + kind: "ok"; + account: Account; +} -interface PollLoginResult { - cancelled: boolean; - loginName: string; +interface PollLoginResultCancelled { + kind: "cancelled"; } -class LoginProcess { +type PollLoginResult = PollLoginResultOk | PollLoginResultCancelled; + +export class LoginFlow { private loginApi: CoreClientFlowLoginV2Api | null = null; - private token: string = ""; + private token: string = ''; private cancelled: boolean = false; + private hostname: string = ''; - hasAppPassword(): boolean { - return NCommon.Preference.password !== ""; - } - - async initLogin(): Promise { + async initLogin(hostname: string): Promise> { + this.hostname = hostname; let apiConfig = new Configuration({ - basePath: "https://" + NCommon.Preference.hostname, + basePath: "https://" + hostname, baseOptions: { headers: { "User-Agent": NCommon.UserAgent() }, } @@ -38,14 +38,14 @@ class LoginProcess { try { let flow = (await this.loginApi.coreClientFlowLoginV2Init()).data; this.token = flow.poll.token; - return { success: true, loginUrl: flow.login }; + return Ok(flow.login); } catch (e) { - let axiosError: AxiosError = e as AxiosError; Logger.Error(TAG, "LoginV2Init failed: " + JSON.stringify(e)); + let axiosError: AxiosError = e as AxiosError; if (Number(axiosError.code) === 2300006) { - return { success: false, errorMsg: (JSON.parse(axiosError.message) as Error).message }; + return Err((JSON.parse(axiosError.message) as Error).message); } else { - return { success: false, errorMsg: axiosError.message }; + return Err("Network error"); } } } @@ -57,23 +57,19 @@ class LoginProcess { async loginPollRepeatedly(): Promise { Logger.Info(TAG, "Polling for login result..."); this.cancelled = false; - let result = await this.loginPollOnce(); - while (!result) { + + let result: CoreLoginFlowV2Credentials | null; + do { if (this.cancelled) { - return { - loginName: '', - cancelled: true - }; + return { kind: "cancelled" }; } await AsyncSleep(1500); result = await this.loginPollOnce(); - } + } while (result === null); - NCommon.Preference.password = result.appPassword; - NCommon.Preference.username = result.loginName; return { - cancelled: false, - loginName: result.loginName, + kind: 'ok', + account: Account.createNewAccount(this.hostname, result.loginName, result.appPassword) }; } @@ -86,5 +82,3 @@ class LoginProcess { } } } - -export { LoginProcess }; diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index a29c4ab..7597521 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -1,8 +1,6 @@ import { deviceInfo } from '@kit.BasicServicesKit'; import { preferences } from '@kit.ArkData'; -import { common } from '@kit.AbilityKit'; import { Logger } from './Utils/Logger'; -import util from '@ohos.util'; const TAG = "Nextcloud-Common"; @@ -44,63 +42,39 @@ export const DEFAULT_CATEGORIES: NCommon.CategoryConfig[] = [ ]; -const LOGIN_APP_PASSWORD = "login.appPassword"; -const LOGIN_USERNAME = "login.username"; -const LOGIN_SERVER = "login.server"; -const UPLOAD_BASE_DIRECTORY = "upload.baseDir"; -const UPLOAD_REPLACE_EXISTING = "upload.replaceExisting"; -const UPLOAD_CATEGORIES = "upload.categories"; +type LOGIN_APP_PASSWORD = "login.appPassword"; +type LOGIN_USERNAME = "login.username"; +type LOGIN_SERVER = "login.server"; +type UPLOAD_BASE_DIRECTORY = "upload.baseDir"; +type UPLOAD_REPLACE_EXISTING = "upload.replaceExisting"; +type UPLOAD_CATEGORIES = "upload.categories"; + +type SETTINGS_KEYS = + LOGIN_APP_PASSWORD | + LOGIN_USERNAME | + LOGIN_SERVER | + UPLOAD_BASE_DIRECTORY | + UPLOAD_REPLACE_EXISTING | + UPLOAD_CATEGORIES; class NPreference { - static SETTINGS_KEYS: string[] = [ - LOGIN_APP_PASSWORD, - LOGIN_USERNAME, - LOGIN_SERVER, - UPLOAD_BASE_DIRECTORY, - UPLOAD_REPLACE_EXISTING, - UPLOAD_CATEGORIES, - ]; private pref: preferences.Preferences; constructor(context: Context) { this.pref = preferences.getPreferencesSync(context, { name: "nextcloud" }); } - get password(): string { - return this.getPref(LOGIN_APP_PASSWORD, ""); - } - - set password(value: string) { - this.setPref(LOGIN_APP_PASSWORD, value); - } - - get username(): string { - return this.getPref(LOGIN_USERNAME, "username"); - } - - set username(value: string) { - this.setPref(LOGIN_USERNAME, value); - } - - get hostname(): string { - return this.getPref(LOGIN_SERVER, "nextcloud.com"); - } - - set hostname(value: string) { - this.setPref(LOGIN_SERVER, value); - } - get replaceExistingFiles(): boolean { - return this.getPref(UPLOAD_REPLACE_EXISTING, false); + return this.getPref('upload.replaceExisting', false); } set replaceExistingFiles(doReplace: boolean) { - this.setPref(UPLOAD_REPLACE_EXISTING, doReplace); + this.setPref('upload.replaceExisting', doReplace); } get baseUploadDirectory(): string { const DEFAULT_VALUE = 'InstantUpload/'; - let dir = this.getPref(UPLOAD_BASE_DIRECTORY, DEFAULT_VALUE); + let dir = this.getPref('upload.baseDir', DEFAULT_VALUE); if (dir === '') { dir = DEFAULT_VALUE; } @@ -108,11 +82,11 @@ class NPreference { } set baseUploadDirectory(dir: string) { - this.setPref(UPLOAD_BASE_DIRECTORY, dir); + this.setPref('upload.baseDir', dir); } get categories(): NCommon.CategoryConfig[] { - let stringConfig = this.getPref(UPLOAD_CATEGORIES, ""); + let stringConfig = this.getPref('upload.categories', ""); if (stringConfig === "") { return DEFAULT_CATEGORIES; } @@ -121,19 +95,10 @@ class NPreference { set categories(categories: NCommon.CategoryConfig[]) { let stringConfig = JSON.stringify(categories); - this.setPref(UPLOAD_CATEGORIES, stringConfig); - } - - public hasLogon() { - return this.password !== '' && this.username !== ''; + this.setPref('upload.categories', stringConfig); } - private getPref(key: string, defaultValue: T): T { - if (!NPreference.SETTINGS_KEYS.includes(key)) { - Logger.Error(TAG, "Invalid preference key: " + key); - return defaultValue; - } - + public getPref(key: SETTINGS_KEYS, defaultValue: T): T { try { let value = this.pref.getSync(key, "") as T; if (value === null) { @@ -148,12 +113,7 @@ class NPreference { } - private setPref(key: string, value: preferences.ValueType) { - if (!NPreference.SETTINGS_KEYS.includes(key)) { - Logger.Error(TAG, "Invalid preference key: " + key); - return; - } - + private setPref(key: SETTINGS_KEYS, value: preferences.ValueType) { Logger.Info(TAG, "Storing value for " + key); try { this.pref.putSync(key, value); @@ -189,26 +149,4 @@ export namespace NCommon { Logger.Info(TAG, "Common initialised with User-Agent: " + UserAgent()); Preference = new NPreference(context); } - - export function MakePreviewUrl(fileId: string, x: number = 64, y: number = 64): string { - return `https://${NCommon.Preference.username}:${NCommon.Preference.password}@${NCommon.Preference.hostname}` - + `/core/preview?fileId=${fileId}&x=${x}&y=${y}&mimeFallback=true`; - } - - export function MakeDownloadUrl(filePath: string): string { - let encodedPath = encodeURI(filePath); - return `https://${NCommon.Preference.username}:${NCommon.Preference.password}@${NCommon.Preference.hostname}` - + `/remote.php/dav/files/${NCommon.Preference.username}/${encodedPath}`; - } - - let Base64Util = new util.Base64Helper(); - let Utf8Encoder = util.TextEncoder.create('utf-8'); - - function Base64Encode(msg: string): string { - return Base64Util.encodeToStringSync(Utf8Encoder.encodeInto(msg)); - } - - export function MakeAuthorization(): string { - return 'basic ' + Base64Encode(NCommon.Preference.username + ":" + NCommon.Preference.password); - } } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index 41b30e1..a7d5b31 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -1,34 +1,39 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../Utils/Logger'; -import { DBAccount, DBAccountFactory } from './Types/DBAccount'; +import { DBAccountFactory } from './Types/DBAccount'; import { DBSyncFolderFactory } from './Types/DBSyncFolder'; -const TAG = 'Utils/StorageAdapter'; +const TAG = 'Database/DBManager'; export interface ColumnDescriptor { - column: string; - type: string; - attrs?: string; + column: string; ///< Column name + type: string; ///< Data type (e.g., INTEGER, TEXT) + attrs?: string; ///< Additional attributes (e.g., PRIMARY KEY, NOT NULL) } export type ColumnDescriptors = ColumnDescriptor[]; -export interface DBObject { +interface DBGetTableName { getTableName(): string; - - toBucket(): relationalStore.ValuesBucket; } -interface DBTableDescriptor { - getTableName(): string; - +interface DBTableDescriptor extends DBGetTableName { getColumnDesc(): ColumnDescriptors; } -export interface DBObjectCreator extends DBTableDescriptor { - createFromBucket(bucket: relationalStore.ValuesBucket): T; +/** + * Interface for database items that can be converted to a ValuesBucket. + */ +export interface DBItem extends DBGetTableName { + toBucket(): relationalStore.ValuesBucket; } +/** + * Interface for creating database objects from a ValuesBucket. + */ +export interface DBObjectCreator extends DBTableDescriptor { + createFromBucket(bucket: relationalStore.ValuesBucket): T; +} export class DBManager { store: relationalStore.RdbStore | undefined = undefined; @@ -143,7 +148,7 @@ export class DBManager { Logger.Debug(TAG, 'doTransaction: completed'); } - async getObjects(factory: DBObjectCreator): Promise { + async getItems(factory: DBObjectCreator): Promise { Logger.Info(TAG, 'getObjects: enter'); if (!this.store) { Logger.Error(TAG, `getObjects: store is undefined`); @@ -165,7 +170,7 @@ export class DBManager { return result; } - async storeObject(object: DBObject): Promise { + async storeItem(object: DBItem): Promise { Logger.Info(TAG, 'storeObject: enter'); if (!this.store) { Logger.Error(TAG, `storeObject: store is undefined`); diff --git a/nextcloud/src/main/ets/Database/Types/DBAccount.ets b/nextcloud/src/main/ets/Database/Types/DBAccount.ets index 7231b61..0b2e298 100644 --- a/nextcloud/src/main/ets/Database/Types/DBAccount.ets +++ b/nextcloud/src/main/ets/Database/Types/DBAccount.ets @@ -1,21 +1,21 @@ import { Logger } from '../../Utils/Logger'; -import { DBObject, ColumnDescriptors, DBObjectCreator } from '../DBManager'; +import { DBItem, ColumnDescriptors, DBObjectCreator } from '../DBManager'; import { relationalStore } from '@kit.ArkData'; const TAG = 'Database/DBAccount'; -export class DBAccount implements DBObject { +export class DBAccount implements DBItem { static readonly DB_TABLE_NAME: string = 'Accounts'; id: number; username: string; password: string; hostname: string; - constructor(id: number, username: string, password: string, hostname: string) { + constructor(id: number, hostname: string, username: string, password: string) { this.id = id; + this.hostname = hostname; this.username = username; this.password = password; - this.hostname = hostname; } public getTableName(): string { @@ -43,7 +43,7 @@ class DBAccountFactoryImpl implements DBObjectCreator { let username = bucket['username'] as string; let password = bucket['password'] as string; let hostname = bucket['hostname'] as string; - return new DBAccount(id, username, password, hostname); + return new DBAccount(id, hostname, username, password); } getColumnDesc(): ColumnDescriptors { diff --git a/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets b/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets index 53c755b..23f0f81 100644 --- a/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets +++ b/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets @@ -1,10 +1,10 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../../Utils/Logger'; -import { ColumnDescriptors, DBObject, DBObjectCreator } from '../DBManager'; +import { ColumnDescriptors, DBItem, DBObjectCreator } from '../DBManager'; const TAG = 'Database/DBSyncFolder'; -export class DBSyncFolder implements DBObject { +export class DBSyncFolder implements DBItem { static readonly DB_TABLE_NAME: string = 'Folders'; id: number; systemPath: string; diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index 3b16c8c..bdaf30c 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -8,7 +8,9 @@ export { ListFiles } from './webdav/Files'; export { DirectoryEntry } from './webdav/DirEntryParserStateMachine'; -export { LoginProcess } from './Account/Login'; +export { LoginFlow } from './Account/LoginFlow'; + +export { Account } from './Account/Account'; export { BasicDataSource } from './Utils/BasicDatasource'; @@ -20,4 +22,6 @@ export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; export { Logger } from './Utils/Logger'; -export { InitialiseNextcloud } from './Main'; +export { AsyncSleep } from './Utils/CommonUtils'; + +export { Nextcloud, InitialiseNextcloud } from './Main'; diff --git a/nextcloud/src/main/ets/Main.ets b/nextcloud/src/main/ets/Main.ets index 327fc58..0374149 100644 --- a/nextcloud/src/main/ets/Main.ets +++ b/nextcloud/src/main/ets/Main.ets @@ -6,6 +6,7 @@ import { SyncFolderManager } from "./SyncEngine/SyncFolderManager"; import { Logger } from "./Utils/Logger"; import { DBManager } from "./Database/DBManager"; import { NCommon } from "./Common"; +import { Account } from "./Account/Account"; const TAG = 'Nextcloud/Init'; @@ -28,6 +29,14 @@ class NextcloudImpl { await this.dbManager.upgradeDatabase(); await this.syncFolderManager.loadData(); } + + public hasAccounts(): boolean { + return this.syncFolderManager.hasAccounts(); + } + + public getAccountById(accountId: number): Account { + return this.syncFolderManager.getAccountById(accountId); + } } export async function InitialiseNextcloud(context: Context): Promise { diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 7c4d8da..9dab8a4 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -4,7 +4,7 @@ import { DBManager } from '../Database/DBManager'; import { Account } from '../Account/Account'; import { DBAccount, DBAccountFactory } from '../Database/Types/DBAccount'; import { NCommon } from '../Common'; -import { DBSyncFolder, DBSyncFolderFactory } from '../Database/Types/DBSyncFolder'; +import { DBSyncFolderFactory } from '../Database/Types/DBSyncFolder'; import { SyncFolder } from './SyncFolder'; const TAG = 'SyncEngine/SyncFolderManager'; @@ -24,8 +24,20 @@ export class SyncFolderManager { await this.loadSyncFolders(); } + public hasAccounts(): boolean { + return this.accounts.size > 0; + } + + public getAccountById(accountId: number): Account { + let account = this.accounts.get(accountId); + if (!account) { + throw new Error(`getAccountById: account id ${accountId} not found`); + } + return account; + } + private async loadSyncFolders() { - let dbFolders = await this.dbManager.getObjects(DBSyncFolderFactory); + let dbFolders = await this.dbManager.getItems(DBSyncFolderFactory); for (let dbFolder of dbFolders) { let ownerAccount = this.accounts.get(dbFolder.accountId); if (!ownerAccount) { @@ -38,17 +50,27 @@ export class SyncFolderManager { } private async loadAccounts(): Promise { - let dbAccounts = await this.dbManager.getObjects(DBAccountFactory); + let dbAccounts = await this.dbManager.getItems(DBAccountFactory); for (let dbAccount of dbAccounts) { let account = new Account(dbAccount); this.accounts.set(dbAccount.id, account); } - if (NCommon.Preference.username.length > 0 && dbAccounts.length === 0) { - Logger.Info(TAG, `loadAccounts: no accounts found, migrate default account`); - let dbAccount = new DBAccount(0, NCommon.Preference.username, NCommon.Preference.password, NCommon.Preference.hostname); - this.accounts.set(dbAccount.id, new Account(dbAccount)); - await this.dbManager.storeObject(dbAccount); + if (dbAccounts.length === 0) { + // No accounts found in database, migrate from preferences if possible + let prefServer = NCommon.Preference.getPref('login.server', ''); + let prefUsername = NCommon.Preference.getPref('login.username', ''); + let prefPassword = NCommon.Preference.getPref('login.appPassword', ''); + if (prefPassword.length > 0) { + Logger.Info(TAG, `loadAccounts: no accounts found, migrate default account '${prefUsername}@${prefServer}' from preferences`); + let dbAccount = new DBAccount(0, prefServer, prefUsername, prefPassword); + this.accounts.set(dbAccount.id, new Account(dbAccount)); + await this.dbManager.storeItem(dbAccount); + } else { + Logger.Info(TAG, `loadAccounts: no accounts found, possibly first run`); + } + } else { + Logger.Info(TAG, `loadAccounts: ${dbAccounts.length} accounts loaded`); } } } diff --git a/nextcloud/src/main/ets/Utils/CommonUtils.ets b/nextcloud/src/main/ets/Utils/CommonUtils.ets index d30005d..44287f0 100644 --- a/nextcloud/src/main/ets/Utils/CommonUtils.ets +++ b/nextcloud/src/main/ets/Utils/CommonUtils.ets @@ -1,3 +1,8 @@ +import util from "@ohos.util"; + +let Base64Util = new util.Base64Helper(); +let Utf8Encoder = util.TextEncoder.create('utf-8'); + export async function AsyncSleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(() => { @@ -5,3 +10,7 @@ export async function AsyncSleep(ms: number): Promise { }, ms); }); } + +export function Base64Encode(msg: string): string { + return Base64Util.encodeToStringSync(Utf8Encoder.encodeInto(msg)); +} diff --git a/nextcloud/src/main/ets/Utils/FilesDataSource.ets b/nextcloud/src/main/ets/Utils/FilesDataSource.ets index 8912399..3ebcf28 100644 --- a/nextcloud/src/main/ets/Utils/FilesDataSource.ets +++ b/nextcloud/src/main/ets/Utils/FilesDataSource.ets @@ -1,11 +1,12 @@ import { BasicDataSource } from './BasicDatasource'; import { DirectoryEntry } from '../webdav/DirEntryParserStateMachine'; import { ListFiles } from '../webdav/Files'; +import { Account } from '../Account/Account'; export class FilesDataSource extends BasicDataSource { - constructor(path: string) { + constructor(account: Account, path: string) { super(); - ListFiles(path).then(entries => { + ListFiles(account, path).then(entries => { entries = entries.sort((a: DirectoryEntry, b: DirectoryEntry) => { // Directories first, then sort by name if (a.isDirectory && !b.isDirectory) { diff --git a/nextcloud/src/main/ets/Utils/Result.ets b/nextcloud/src/main/ets/Utils/Result.ets new file mode 100644 index 0000000..11fe677 --- /dev/null +++ b/nextcloud/src/main/ets/Utils/Result.ets @@ -0,0 +1,25 @@ +interface ResultOk { + kind: 'ok'; + data: T; +} + +export function Ok(data: T): ResultOk { + return { + kind: 'ok', + data: data, + }; +} + +interface ResultError { + kind: 'error'; + error: E; +} + +export function Err(error: E): ResultError { + return { + kind: 'error', + error: error, + }; +} + +export type Result = ResultOk | ResultError; diff --git a/nextcloud/src/main/ets/webdav/Files.ets b/nextcloud/src/main/ets/webdav/Files.ets index fb7b54c..c6f44c2 100644 --- a/nextcloud/src/main/ets/webdav/Files.ets +++ b/nextcloud/src/main/ets/webdav/Files.ets @@ -3,18 +3,19 @@ import { Logger } from '../Utils/Logger'; import { NCommon } from '../Common'; import { xml } from '@kit.ArkTS'; import { DirectoryEntry, DirEntryParserStateMachine } from './DirEntryParserStateMachine'; +import { Account } from '../Account/Account'; const TAG = "Nextcloud-Files"; let session: rcp.Session | null = null; -export async function ListFiles(path: string): Promise { - path = "/remote.php/dav/files/" + NCommon.Preference.username + path; +export async function ListFiles(account: Account, path: string): Promise { + let url = account.remoteBaseDirUrl() + path; Logger.Info(TAG, "Listing files at " + path); let sessionConfiguration: rcp.SessionConfiguration = { headers: { - 'authorization': NCommon.MakeAuthorization(), + 'authorization': account.makeAuthorizationHeader(), 'user-agent': NCommon.UserAgent(), 'accept': 'text/plains' } @@ -25,7 +26,7 @@ export async function ListFiles(path: string): Promise { } let req = - new rcp.Request("https://" + NCommon.Preference.hostname + path, "PROPFIND", {}, + new rcp.Request(url, "PROPFIND", {}, '\n' + ' \n' + ' \n' + @@ -71,5 +72,6 @@ export async function ListFiles(path: string): Promise { parser.parseXml(options) - return machine.getDirEntryList(path); + const prefix = '/remote.php/dav/files/' + account.username + path; + return machine.getDirEntryList(prefix); } diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets index 3233e9f..49a3d66 100644 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ b/nextcloud/src/main/ets/webdav/Uploader.ets @@ -2,13 +2,14 @@ import { rcp } from '@kit.RemoteCommunicationKit'; import { NCommon } from '../Common'; import fs from '@ohos.file.fs'; import { Logger } from '../Utils/Logger'; -import { TransferManager, TransferTask, TransferType } from '../TransferManager'; +import { TransferManager, TransferType } from '../TransferManager'; +import { Account } from '../Account/Account'; const TAG = "Nextcloud-Upload"; -function CreateSession(extraHeaders?: Map, eventsHandler?: rcp.HttpEventsHandler): rcp.Session { +function CreateSession(account: Account, extraHeaders?: Map, eventsHandler?: rcp.HttpEventsHandler): rcp.Session { let headers: rcp.RequestHeaders = {}; if (extraHeaders !== undefined) { @@ -17,7 +18,7 @@ function CreateSession(extraHeaders?: Map, eventsHandler?: rcp.H } } - headers['authorization'] = NCommon.MakeAuthorization(); + headers['authorization'] = account.makeAuthorizationHeader(); headers['user-agent'] = NCommon.UserAgent(); headers['accept'] = 'text/plains'; @@ -38,8 +39,8 @@ function CreateSession(extraHeaders?: Map, eventsHandler?: rcp.H } } -export async function Exists(session: rcp.Session, path: string, filename: string = ""): Promise { - let fullPath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + path + "/" + filename; +export async function Exists(session: rcp.Session, account: Account, path: string, filename: string = ""): Promise { + let fullPath = account.remoteBaseDirUrl() + path + "/" + filename; Logger.Info(TAG, "Checking if path exists: " + fullPath); try { @@ -60,9 +61,9 @@ export async function Exists(session: rcp.Session, path: string, filename: strin } } -export async function CreatePath(path: string): Promise { - let session = CreateSession(); - if (await Exists(session, path)) { +export async function CreatePath(account: Account, path: string): Promise { + let session = CreateSession(account); + if (await Exists(session, account, path)) { Logger.Info(TAG, "Path already exists: " + path); session.close(); return 201; @@ -75,7 +76,7 @@ export async function CreatePath(path: string): Promise { if (!thisLevel) { continue; } - let fullPath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + thisLevel + "/"; + let fullPath = account.remoteBaseDirUrl() + thisLevel + "/"; try { let resp = await session.fetch(new rcp.Request(fullPath, "MKCOL", {})); Logger.Info(TAG, `Create path '${thisLevel}', response: ${resp.statusCode}`); @@ -95,7 +96,7 @@ export async function CreatePath(path: string): Promise { return respCode; } -export async function DownloadFile(relativePath: string, localPath: string): Promise { +export async function DownloadFile(account: Account, relativePath: string, localPath: string): Promise { let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, relativePath); let eventsHandler: rcp.HttpEventsHandler = { @@ -104,16 +105,13 @@ export async function DownloadFile(relativePath: string, localPath: string): Pro } }; - let session = CreateSession(undefined, eventsHandler); + let session = CreateSession(account, undefined, eventsHandler); let downloadToFileOptions: rcp.DownloadToFile = { kind: "file", file: localPath, }; - await session.downloadToFile( - "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + relativePath, - downloadToFileOptions - ).then(() => { + await session.downloadToFile(account.remoteBaseDirUrl() + relativePath, downloadToFileOptions).then(() => { task.updateProgress(task.totalSize, task.totalSize); }).catch((error: BusinessError) => { Logger.Error(TAG, `Download failed: ${relativePath}, error: ${JSON.stringify(error)}`); @@ -122,13 +120,13 @@ export async function DownloadFile(relativePath: string, localPath: string): Pro session.close(); } -export async function UploadFile(filename: string, mTime: number, path: string, uri: string): Promise { +export async function UploadFile(account: Account, filename: string, mTime: number, path: string, uri: string): Promise { let extraHeaders = new Map(); extraHeaders.set('X-OC-MTime', `${Math.round(mTime / 1000)}`); - let session = CreateSession(extraHeaders); + let session = CreateSession(account, extraHeaders); if (!NCommon.Preference.replaceExistingFiles) { - if (await Exists(session, path, filename)) { + if (await Exists(session, account, path, filename)) { Logger.Info(TAG, `file already exists: ${filename}, not replacing as per settings`); session.close(); return 204; // No Content, file already exists @@ -145,7 +143,7 @@ export async function UploadFile(filename: string, mTime: number, path: string, throw error as Error; } - let fullpath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + path + "/" + filename; + let fullpath = account.remoteBaseDirUrl() + path + "/" + filename; Logger.Info(TAG, "Uploading file to " + fullpath); let ret = -1; From f4583a34d146fab7ec6b1981f6508f134806001b Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sat, 10 Jan 2026 23:24:58 +0800 Subject: [PATCH 07/16] refactor DBManager --- entry/src/main/ets/models/PhotoItem.ets | 2 +- entry/src/main/ets/pages/PcMainPage.ets | 2 +- entry/src/main/ets/pages/SettingsPage.ets | 21 ++- .../src/main/ets/statusbar/StatusBarPage.ets | 7 +- .../src/main/ets/utils/PhotoAssetsManager.ets | 1 + nextcloud/src/main/ets/Account/Account.ets | 24 +++- nextcloud/src/main/ets/Database/DBManager.ets | 130 +++++++++++++----- .../src/main/ets/Database/Types/DBAccount.ets | 35 ++--- .../ets/Database/Types/DBLocalSyncFolder.ets | 75 ++++++++++ .../ets/Database/Types/DBRemoteFolder.ets | 61 ++++++++ .../main/ets/Database/Types/DBSyncFolder.ets | 61 -------- nextcloud/src/main/ets/Index.ets | 2 +- nextcloud/src/main/ets/Main.ets | 4 + .../src/main/ets/SyncEngine/SyncFolder.ets | 6 +- .../main/ets/SyncEngine/SyncFolderManager.ets | 8 +- .../ets/{ => SyncEngine}/TransferManager.ets | 33 +++-- .../src/main/ets/Utils/FilesDataSource.ets | 16 ++- .../ets/webdav/DirEntryParserStateMachine.ts | 8 +- nextcloud/src/main/ets/webdav/Files.ets | 2 +- nextcloud/src/main/ets/webdav/Uploader.ets | 6 +- 20 files changed, 324 insertions(+), 180 deletions(-) create mode 100644 nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets create mode 100644 nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets delete mode 100644 nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets rename nextcloud/src/main/ets/{ => SyncEngine}/TransferManager.ets (74%) diff --git a/entry/src/main/ets/models/PhotoItem.ets b/entry/src/main/ets/models/PhotoItem.ets index a63c1cf..0e0662b 100644 --- a/entry/src/main/ets/models/PhotoItem.ets +++ b/entry/src/main/ets/models/PhotoItem.ets @@ -1,7 +1,7 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; import { CreatePath, Logger, Nextcloud, UploadFile } from 'nextcloud'; import { promptAction } from '@kit.ArkUI'; -import { FormatTime } from '../utils/Utils'; +import { FormatTime, ShowToast } from '../utils/Utils'; import { image } from '@kit.ImageKit'; const TAG = 'PhotoItem'; diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 7ae35fa..ff63da6 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -23,7 +23,7 @@ struct TransferProgressItem { .textAlign(TextAlign.End) Progress({ - value: this.transferTask.transferedSize, + value: this.transferTask.transferredSize, total: this.transferTask.totalSize, }) diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 7388a89..9a5d65c 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -1,5 +1,7 @@ -import { Logger, NCommon, Account } from 'nextcloud'; +import { Account, Logger, NCommon, Nextcloud } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; +import application from '@ohos.app.ability.application'; +import { ShowToast } from '../utils/Utils'; const TAG = 'pages/Settings'; @@ -111,7 +113,7 @@ export struct SettingsPage { .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) - ForEach(this.uploadCategories, (category: EditableCategory, index: number) => { + ForEach(this.uploadCategories, (category: EditableCategory, _index: number) => { Column({ space: 4 }) { Row() { Text(category.name) @@ -148,11 +150,20 @@ export struct SettingsPage { Button($r('app.string.save_settings'), { stateEffect: true }) .onClick(() => { this.doSaveSettings(); - this.getUIContext().getPromptAction().showToast({ - message: 'settings saved' - }); + ShowToast(this.getUIContext(), 'settings saved'); }) .margin({ top: 16, bottom: 16 }) + + Button('DROP DATABASE') + .buttonStyle(ButtonStyleMode.EMPHASIZED) + .onClick(async () => { + await Nextcloud.recreateDatabase(); + try { + await application.getApplicationContext().killAllProcesses(); + } catch (error) { + Logger.Error(TAG, `failed to kill all processes: ${JSON.stringify(error)}`); + } + }) } .height('100%') } diff --git a/entry/src/main/ets/statusbar/StatusBarPage.ets b/entry/src/main/ets/statusbar/StatusBarPage.ets index 2ec8590..fa794ad 100644 --- a/entry/src/main/ets/statusbar/StatusBarPage.ets +++ b/entry/src/main/ets/statusbar/StatusBarPage.ets @@ -13,7 +13,7 @@ struct TransferProgressItem { .width('100%') Progress({ - value: this.transferTask.transferedSize, + value: this.transferTask.transferredSize, total: this.transferTask.totalSize, }) .flexGrow(1) @@ -31,8 +31,7 @@ struct TransferProgressItem { @Entry @Component struct StatusBarPage { - @State tasks: TransferTask[] = [ - ]; + @State tasks: TransferTask[] = []; aboutToAppear(): void { for (let i = 0; i < 80; i++) { @@ -71,4 +70,4 @@ struct StatusBarPage { .width('100%') .height('100%') } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/utils/PhotoAssetsManager.ets b/entry/src/main/ets/utils/PhotoAssetsManager.ets index 9b2c07f..52f0f5c 100644 --- a/entry/src/main/ets/utils/PhotoAssetsManager.ets +++ b/entry/src/main/ets/utils/PhotoAssetsManager.ets @@ -72,6 +72,7 @@ class PhotoAssetsManagerImpl { } public async doFullUpload(notify: boolean = true) { + Logger.Info(TAG, `doFullUpload enter, notify: ${notify}`); for (let category of this.categories) { await category.uploadAllAssets(notify); } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index c6771c9..0bb1eef 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -4,14 +4,24 @@ import { Logger } from '../Utils/Logger'; const TAG = 'Account/Account'; -export class Account extends DBAccount { +export class Account { + dbAccount: DBAccount; + constructor(dbAccount: DBAccount) { Logger.Info(TAG, `constructor: enter`); - super(dbAccount.id, dbAccount.hostname, dbAccount.username, dbAccount.password); + this.dbAccount = dbAccount; + } + + public get username(): string { + return this.dbAccount.username; + } + + public get hostname(): string { + return this.dbAccount.hostname; } private get baseUrlWithCredentials(): string { - return `https://${this.username}:${this.password}@${this.hostname}`; + return `https://${this.dbAccount.username}:${this.dbAccount.password}@${this.dbAccount.hostname}`; } public static createNewAccount(hostname: string, username: string, password: string): Account { @@ -21,15 +31,15 @@ export class Account extends DBAccount { } public isLoggedIn(): boolean { - return this.password !== ''; + return this.dbAccount.password !== ''; } public makeAuthorizationHeader(): string { - return 'basic ' + Base64Encode(this.username + ":" + this.password); + return 'basic ' + Base64Encode(this.dbAccount.username + ":" + this.dbAccount.password); } public remoteBaseDirUrl(): string { - return `https://${this.hostname}/remote.php/dav/files/${this.username}/`; + return `https://${this.dbAccount.hostname}/remote.php/dav/files/${this.dbAccount.username}/`; } public getPreviewUrl(fileId: string, x: number = 64, y: number = 64): string { @@ -38,6 +48,6 @@ export class Account extends DBAccount { public getDownloadUrl(path: string): string { let encodedPath = encodeURI(path); - return this.baseUrlWithCredentials + `/remote.php/dav/files/${this.username}/${encodedPath}`; + return this.baseUrlWithCredentials + `/remote.php/dav/files/${this.dbAccount.username}/${encodedPath}`; } } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index a7d5b31..c791bcf 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -1,60 +1,67 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../Utils/Logger'; -import { DBAccountFactory } from './Types/DBAccount'; -import { DBSyncFolderFactory } from './Types/DBSyncFolder'; +import { DBAccountTableInfo } from './Types/DBAccount'; +import { DBLocalSyncFolderTableInfo } from './Types/DBLocalSyncFolder'; +import { DBRemoteFolderTable } from './Types/DBRemoteFolder'; +import { DEBUG } from '../../../../BuildProfile'; const TAG = 'Database/DBManager'; +const CAN_RECREATE_DATABASE = false; + +interface ForeignKeyDescriptor { + target: DBTableDescriptor; + targetColumn: ColumnDescriptor; +} + export interface ColumnDescriptor { - column: string; ///< Column name type: string; ///< Data type (e.g., INTEGER, TEXT) attrs?: string; ///< Additional attributes (e.g., PRIMARY KEY, NOT NULL) + foreignKey?: ForeignKeyDescriptor; } -export type ColumnDescriptors = ColumnDescriptor[]; - -interface DBGetTableName { - getTableName(): string; -} - -interface DBTableDescriptor extends DBGetTableName { - getColumnDesc(): ColumnDescriptors; +export interface DBTableDescriptor { + readonly tableName: string, + readonly columns: Record; + readonly createItemFromBucket: (bucket: relationalStore.ValuesBucket) => TItem; } /** * Interface for database items that can be converted to a ValuesBucket. */ -export interface DBItem extends DBGetTableName { +export interface DBItem { + getTableName(): string; + toBucket(): relationalStore.ValuesBucket; } -/** - * Interface for creating database objects from a ValuesBucket. - */ -export interface DBObjectCreator extends DBTableDescriptor { - createFromBucket(bucket: relationalStore.ValuesBucket): T; -} +const TABLES: DBTableDescriptor[] = [ + DBAccountTableInfo, + DBRemoteFolderTable, + DBLocalSyncFolderTableInfo, +]; export class DBManager { store: relationalStore.RdbStore | undefined = undefined; - static generateTableCreationScript(descriptor: DBTableDescriptor): string { + static generateTableCreationScript(tableName: string, columnDesc: Record): string { + let foreignKeyDefs: string[] = []; let columnsDef: string[] = []; - for (let col of descriptor.getColumnDesc()) { - let colDef = `${col.column} ${col.type}`; - if (col.attrs) { - colDef += ` ${col.attrs}`; + for (let colName of Object.keys(columnDesc)) { + let colDesc = columnDesc[colName]; + let colDef = `${colName} ${colDesc.type}`; + if (colDesc.attrs) { + colDef += ` ${colDesc.attrs}`; } columnsDef.push(colDef); + if (colDesc.foreignKey) { + let fk = colDesc.foreignKey; + let fkTargetColumn: string = Object.keys(fk.target.columns).find(key => fk.target.columns[key] === fk.targetColumn)!; + foreignKeyDefs.push(`FOREIGN KEY(${colName}) REFERENCES ${fk.target.tableName}(${fkTargetColumn})`); + } } - return `CREATE TABLE IF NOT EXISTS ${descriptor.getTableName()} (${columnsDef.join(', ')});`; - } - - static gatherTableCreationScripts(): string[] { - let result: string[] = []; - result.push(DBManager.generateTableCreationScript(DBAccountFactory)); - result.push(DBManager.generateTableCreationScript(DBSyncFolderFactory)); - return result; + columnsDef.push(...foreignKeyDefs); // Add foreign key definitions to columns at the end + return `CREATE TABLE ${tableName} (\n\t${columnsDef.join(',\n\t')});`; } async initDataStore(context: Context, storageName: string) { @@ -96,9 +103,11 @@ export class DBManager { try { let storeVersion = await transaction.execute('PRAGMA user_version'); if (storeVersion === 0) { - const creationScripts = DBManager.gatherTableCreationScripts(); - for (let sql of creationScripts) { - await transaction.execute(sql); + for (let tableDesc of TABLES) { + let createSql = DBManager.generateTableCreationScript(tableDesc.tableName, tableDesc.columns); + Logger.Info(TAG, `upgradeDatabase: creating table with sql: ${createSql}`); + let result = await transaction.execute(createSql); + Logger.Info(TAG, `upgradeDatabase: created table ${tableDesc.tableName}, result: ${JSON.stringify(result)}`); } await transaction.execute('PRAGMA user_version = 1'); storeVersion = 1; @@ -148,7 +157,7 @@ export class DBManager { Logger.Debug(TAG, 'doTransaction: completed'); } - async getItems(factory: DBObjectCreator): Promise { + async getItems(factory: DBTableDescriptor): Promise { Logger.Info(TAG, 'getObjects: enter'); if (!this.store) { Logger.Error(TAG, `getObjects: store is undefined`); @@ -157,10 +166,10 @@ export class DBManager { let result: T[] = []; try { - let resultSet = await this.store.query(new relationalStore.RdbPredicates(factory.getTableName())); + let resultSet = await this.store.query(new relationalStore.RdbPredicates(factory.tableName)); while (resultSet.goToNextRow()) { let bucket = resultSet.getRow(); - result.push(factory.createFromBucket(bucket)); + result.push(factory.createItemFromBucket(bucket)); } } catch (error) { Logger.Error(TAG, `getObjects: failed to read result set: ${JSON.stringify(error)}`); @@ -179,11 +188,56 @@ export class DBManager { let bucket = object.toBucket(); try { - await this.store.insert(object.getTableName(), bucket); + let row = await this.store.insert(object.getTableName(), bucket); + if (!row) { + Logger.Error(TAG, `storeObject: failed to insert account, no row returned`); + return; + } } catch (error) { Logger.Error(TAG, `storeObject: failed to insert account: ${JSON.stringify(error)}`); return; } Logger.Info(TAG, 'storeObject: completed'); } + + async dropDatabase() { + if (this.store === undefined) { + Logger.Error(TAG, `dropAllTables: store is undefined`); + return; + } + + Logger.Warn(TAG, `dropAllTables: enter, SHOULD_RECREATE_DATABASE=${CAN_RECREATE_DATABASE}, DEBUG=${DEBUG}`); + if (CAN_RECREATE_DATABASE && DEBUG) { + Logger.Warn(TAG, 'upgradeDatabase: recreating database as per ALWAYS_RECREATE_DATABASE flag'); + try { + for (let tableDesc of TABLES) { + let dropSql = `DROP TABLE IF EXISTS ${tableDesc.tableName};`; + await this.store.execute(dropSql); + Logger.Warn(TAG, `dropAllTables: dropped table ${tableDesc.tableName}`); + } + await this.store.execute('PRAGMA user_version = 0'); + Logger.Warn(TAG, 'dropAllTables: reset user_version to 0'); + // drop all other tables + let tablesResult = await this.store.querySql('SELECT name FROM sqlite_master WHERE type="table";'); + Logger.Warn(TAG, `dropAllTables: checking for leftover tables: ${tablesResult.rowCount}`); + while (tablesResult.goToNextRow()) { + let row = tablesResult.getRow(); + let tableName = row['name'] as string; + Logger.Warn(TAG, `dropAllTables: leftover table ${tableName}`); + if (tableName === 'sqlite_sequence') { + Logger.Warn(TAG, `dropAllTables: skipping sqlite_sequence table`); + continue; + } + await this.store.execute(`DROP TABLE IF EXISTS ${tableName};`); + } + Logger.Warn(TAG, 'dropAllTables: database dropped, closing store'); + await this.store.close(); + } catch (e) { + Logger.Error(TAG, `dropAllTables: failed to execute sql: ${JSON.stringify(e)}`); + } + Logger.Warn(TAG, 'dropAllTables: completed'); + } else { + Logger.Warn(TAG, 'dropAllTables: operation is disabled'); + } + } } diff --git a/nextcloud/src/main/ets/Database/Types/DBAccount.ets b/nextcloud/src/main/ets/Database/Types/DBAccount.ets index 0b2e298..2624d3f 100644 --- a/nextcloud/src/main/ets/Database/Types/DBAccount.ets +++ b/nextcloud/src/main/ets/Database/Types/DBAccount.ets @@ -1,5 +1,5 @@ import { Logger } from '../../Utils/Logger'; -import { DBItem, ColumnDescriptors, DBObjectCreator } from '../DBManager'; +import { DBItem, DBTableDescriptor } from '../DBManager'; import { relationalStore } from '@kit.ArkData'; const TAG = 'Database/DBAccount'; @@ -12,6 +12,7 @@ export class DBAccount implements DBItem { hostname: string; constructor(id: number, hostname: string, username: string, password: string) { + Logger.Debug(TAG, `constructor: enter, id=${id}, hostname=${hostname}, username=${username}`); this.id = id; this.hostname = hostname; this.username = username; @@ -32,29 +33,21 @@ export class DBAccount implements DBItem { } } -class DBAccountFactoryImpl implements DBObjectCreator { - getTableName(): string { - return DBAccount.DB_TABLE_NAME; - } - - createFromBucket(bucket: relationalStore.ValuesBucket): DBAccount { - Logger.Info(TAG, `createFromBucket: enter, row: ${JSON.stringify(bucket)}`); +type DBAccountColumnNames = 'account_id' | 'hostname' | 'username' | 'password'; + +export const DBAccountTableInfo: DBTableDescriptor = { + tableName: DBAccount.DB_TABLE_NAME, + columns: { + 'account_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'hostname': { type: 'TEXT', attrs: 'NOT NULL' }, + 'username': { type: 'TEXT', attrs: 'NOT NULL' }, + 'password': { type: 'TEXT', attrs: 'NOT NULL' }, + }, + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBAccount { let id = bucket['account_id'] as number; let username = bucket['username'] as string; let password = bucket['password'] as string; let hostname = bucket['hostname'] as string; return new DBAccount(id, hostname, username, password); } - - getColumnDesc(): ColumnDescriptors { - const desc: ColumnDescriptors = [ - { column: 'account_id', type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, - { column: 'hostname', type: 'TEXT', attrs: 'NOT NULL' }, - { column: 'username', type: 'TEXT', attrs: 'NOT NULL' }, - { column: 'password', type: 'TEXT', attrs: 'NOT NULL' }, - ]; - return desc; - } -} - -export const DBAccountFactory = new DBAccountFactoryImpl(); +}; diff --git a/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets b/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets new file mode 100644 index 0000000..e96ad6e --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets @@ -0,0 +1,75 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { DBItem, DBTableDescriptor } from '../DBManager'; +import { DBAccountTableInfo } from './DBAccount'; +import { DBRemoteFolderTable } from './DBRemoteFolder'; + +const TAG = 'Database/DBSyncFolder'; + +export class DBLocalSyncFolder implements DBItem { + static readonly DB_TABLE_NAME: string = 'LocalSyncFolders'; + id: number; + localPath: string; + alias: string; + accountId: number; + remoteFolderId: number; + + constructor(id: number, localPath: string, alias: string, account_id: number, remote_folder_id: number) { + Logger.Info(TAG, `constructor: enter, id=${id}, localPath=${localPath}, alias=${alias}, account_id=${account_id}, remote_folder_id=${remote_folder_id}`); + this.id = id; + this.localPath = localPath; + this.alias = alias; + this.accountId = account_id; + this.remoteFolderId = remote_folder_id; + } + + public getTableName(): string { + return DBLocalSyncFolder.DB_TABLE_NAME; + } + + public toBucket(): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + bucket['folder_id'] = this.id; + bucket['local_path'] = this.localPath; + bucket['alias'] = this.alias; + bucket['account_id'] = this.accountId; + bucket['remote_folder_id'] = this.remoteFolderId; + return bucket; + } +} + +type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'account_id' | 'remote_folder_id'; + +export const DBLocalSyncFolderTableInfo: DBTableDescriptor = { + tableName: DBLocalSyncFolder.DB_TABLE_NAME, + columns: { + 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'account_id': { + type: 'INTEGER', + attrs: 'NOT NULL', + foreignKey: { + target: DBAccountTableInfo, + targetColumn: DBAccountTableInfo.columns.account_id, + }, + }, + 'local_path': { type: 'TEXT', attrs: 'NOT NULL' }, + 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, + 'remote_folder_id': { + type: 'INTEGER', + attrs: 'NOT NULL', + foreignKey: { + target: DBRemoteFolderTable, + targetColumn: DBRemoteFolderTable.columns.folder_id, + }, + }, + }, + + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalSyncFolder { + let id = bucket['folder_id'] as number; + let localPath = bucket['local_path'] as string; + let alias = bucket['alias'] as string; + let account_id = bucket['account_id'] as number; + let remote_folder_id = bucket['remote_folder_id'] as number; + return new DBLocalSyncFolder(id, localPath, alias, account_id, remote_folder_id); + } +}; diff --git a/nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets b/nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets new file mode 100644 index 0000000..b198b51 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets @@ -0,0 +1,61 @@ +import { DBItem, DBTableDescriptor } from '../DBManager'; +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { DBAccountTableInfo } from './DBAccount'; + +const TAG = 'Database/DBRemoteFolder'; + +export class DBRemoteFolder implements DBItem { + static readonly DB_TABLE_NAME: string = 'RemoteFolders'; + id: number; + accountId: number; + remotePath: string; + alias: string; + + constructor(id: number, accountId: number, remotePath: string, alias: string) { + Logger.Info(TAG, `constructor: enter, id=${id}, accountId=${accountId}, remotePath=${remotePath}, alias=${alias}`); + this.id = id; + this.accountId = accountId; + this.remotePath = remotePath; + this.alias = alias; + } + + public getTableName(): string { + return DBRemoteFolder.DB_TABLE_NAME; + } + + public toBucket(): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + bucket['folder_id'] = this.id; + bucket['account_id'] = this.accountId; + bucket['remote_path'] = this.remotePath; + bucket['alias'] = this.alias; + return bucket; + } +} + +type DBRemoteFolderColumnNames = 'folder_id' | 'remote_path' | 'alias' | 'account_id'; + +export const DBRemoteFolderTable: DBTableDescriptor = { + tableName: DBRemoteFolder.DB_TABLE_NAME, + columns: { + 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'remote_path': { type: 'TEXT', attrs: 'NOT NULL' }, + 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, + 'account_id': { + type: 'INTEGER', + attrs: 'NOT NULL', + foreignKey: { + target: DBAccountTableInfo, + targetColumn: DBAccountTableInfo.columns.account_id, + }, + }, + }, + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBRemoteFolder { + let id = bucket['folder_id'] as number; + let remotePath = bucket['remote_path'] as string; + let alias = bucket['alias'] as string; + let account_id = bucket['account_id'] as number; + return new DBRemoteFolder(id, account_id, remotePath, alias); + } +}; diff --git a/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets b/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets deleted file mode 100644 index 23f0f81..0000000 --- a/nextcloud/src/main/ets/Database/Types/DBSyncFolder.ets +++ /dev/null @@ -1,61 +0,0 @@ -import { relationalStore } from '@kit.ArkData'; -import { Logger } from '../../Utils/Logger'; -import { ColumnDescriptors, DBItem, DBObjectCreator } from '../DBManager'; - -const TAG = 'Database/DBSyncFolder'; - -export class DBSyncFolder implements DBItem { - static readonly DB_TABLE_NAME: string = 'Folders'; - id: number; - systemPath: string; - alias: string; - accountId: number; - - constructor(id: number, systemPath: string, alias: string, account_id: number) { - this.id = id; - this.systemPath = systemPath; - this.alias = alias; - this.accountId = account_id; - } - - public getTableName(): string { - return DBSyncFolder.DB_TABLE_NAME; - } - - public toBucket(): relationalStore.ValuesBucket { - let bucket: relationalStore.ValuesBucket = {}; - bucket['folder_id'] = this.id; - bucket['system_path'] = this.systemPath; - bucket['alias'] = this.alias; - bucket['account_id'] = this.accountId; - return bucket; - } -} - -class DBSyncFolderFactoryImpl implements DBObjectCreator { - static readonly DB_COLUMN_DESC: ColumnDescriptors = [ - { column: 'folder_id', type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, - { column: 'system_path', type: 'TEXT', attrs: 'NOT NULL' }, - { column: 'alias', type: 'TEXT', attrs: 'NOT NULL' }, - { column: 'account_id', type: 'INTEGER', attrs: 'NOT NULL' }, - ]; - - getTableName(): string { - return DBSyncFolder.DB_TABLE_NAME; - } - - createFromBucket(bucket: relationalStore.ValuesBucket): DBSyncFolder { - Logger.Info(TAG, `createFromBucket: enter, row: ${JSON.stringify(bucket)}`); - let id = bucket['folder_id'] as number; - let systemPath = bucket['system_path'] as string; - let alias = bucket['alias'] as string; - let account_id = bucket['account_id'] as number; - return new DBSyncFolder(id, systemPath, alias, account_id); - } - - getColumnDesc(): ColumnDescriptors { - return DBSyncFolderFactoryImpl.DB_COLUMN_DESC; - } -} - -export const DBSyncFolderFactory = new DBSyncFolderFactoryImpl(); diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index bdaf30c..57fe879 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -16,7 +16,7 @@ export { BasicDataSource } from './Utils/BasicDatasource'; export { FilesDataSource } from './Utils/FilesDataSource'; -export { TransferManager, TransferManagerImpl, TransferTask, TransferType } from './TransferManager'; +export { TransferManager, TransferManagerImpl, TransferTask, TransferType } from './SyncEngine/TransferManager'; export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; diff --git a/nextcloud/src/main/ets/Main.ets b/nextcloud/src/main/ets/Main.ets index 0374149..5494917 100644 --- a/nextcloud/src/main/ets/Main.ets +++ b/nextcloud/src/main/ets/Main.ets @@ -37,6 +37,10 @@ class NextcloudImpl { public getAccountById(accountId: number): Account { return this.syncFolderManager.getAccountById(accountId); } + + public async recreateDatabase() { + await this.dbManager.dropDatabase(); + } } export async function InitialiseNextcloud(context: Context): Promise { diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets index 61e0652..a846332 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets @@ -1,15 +1,15 @@ // Describes the root sync folder that's in the system import { Account } from "../Account/Account"; -import { DBSyncFolder } from "../Database/Types/DBSyncFolder"; +import { DBLocalSyncFolder } from "../Database/Types/DBLocalSyncFolder"; const TAG = 'SyncEngine/SyncFolder'; export class SyncFolder { ownerAccount: Account; - dbSyncFolder: DBSyncFolder; + dbSyncFolder: DBLocalSyncFolder; syncTimerId: number = 0; - constructor(dbSyncFolder: DBSyncFolder, account: Account) { + constructor(dbSyncFolder: DBLocalSyncFolder, account: Account) { this.dbSyncFolder = dbSyncFolder; this.ownerAccount = account; } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 9dab8a4..59a9966 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -2,10 +2,10 @@ import { Logger } from '../Utils/Logger'; import { DBManager } from '../Database/DBManager'; import { Account } from '../Account/Account'; -import { DBAccount, DBAccountFactory } from '../Database/Types/DBAccount'; +import { DBAccount, DBAccountTableInfo as DBAccountTable } from '../Database/Types/DBAccount'; import { NCommon } from '../Common'; -import { DBSyncFolderFactory } from '../Database/Types/DBSyncFolder'; import { SyncFolder } from './SyncFolder'; +import { DBLocalSyncFolderTableInfo as DBLocalSyncFolderTable } from '../Database/Types/DBLocalSyncFolder'; const TAG = 'SyncEngine/SyncFolderManager'; @@ -37,7 +37,7 @@ export class SyncFolderManager { } private async loadSyncFolders() { - let dbFolders = await this.dbManager.getItems(DBSyncFolderFactory); + let dbFolders = await this.dbManager.getItems(DBLocalSyncFolderTable); for (let dbFolder of dbFolders) { let ownerAccount = this.accounts.get(dbFolder.accountId); if (!ownerAccount) { @@ -50,7 +50,7 @@ export class SyncFolderManager { } private async loadAccounts(): Promise { - let dbAccounts = await this.dbManager.getItems(DBAccountFactory); + let dbAccounts = await this.dbManager.getItems(DBAccountTable); for (let dbAccount of dbAccounts) { let account = new Account(dbAccount); this.accounts.set(dbAccount.id, account); diff --git a/nextcloud/src/main/ets/TransferManager.ets b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets similarity index 74% rename from nextcloud/src/main/ets/TransferManager.ets rename to nextcloud/src/main/ets/SyncEngine/TransferManager.ets index d3c05a9..70192a3 100644 --- a/nextcloud/src/main/ets/TransferManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets @@ -1,4 +1,4 @@ -import { BasicDataSource } from './Utils/BasicDatasource'; +import { BasicDataSource } from '../Utils/BasicDatasource'; export enum TransferType { DOWNLOAD, @@ -9,26 +9,25 @@ export enum TransferType { export class TransferTask { public readonly type: TransferType; public readonly filename: string; - public totalSize: number = 1; - public transferedSize: number = 0; + public transferredSize: number = 0; - public updateProgress(transferSize: number, totalSize: number) { - if (this.totalSize != totalSize) { - this.totalSize = totalSize; - } - this.transferedSize = transferSize; - return this.transferedSize / this.totalSize; + constructor(type: TransferType, filename: string) { + this.type = type; + this.filename = filename; } public get progressPercentText() { - let progress = this.transferedSize / this.totalSize; + let progress = this.transferredSize / this.totalSize; return `${(progress * 100).toFixed(1)}%`; } - constructor(type: TransferType, filename: string) { - this.type = type; - this.filename = filename; + public updateProgress(transferredSize: number, totalSize: number): number { + if (this.totalSize != totalSize) { + this.totalSize = totalSize; + } + this.transferredSize = transferredSize; + return this.transferredSize / this.totalSize; } } @@ -36,14 +35,14 @@ export class TransferTask { export class TransferManagerImpl { private tasks_: BasicDataSource; - public get tasks() { - return this.tasks_; - } - constructor() { this.tasks_ = new BasicDataSource(); } + public get tasks() { + return this.tasks_; + } + public addTransferTask(type: TransferType, fileName: string): TransferTask { let task = new TransferTask(type, fileName); return this.tasks_.prependItem(task); diff --git a/nextcloud/src/main/ets/Utils/FilesDataSource.ets b/nextcloud/src/main/ets/Utils/FilesDataSource.ets index 3ebcf28..831884c 100644 --- a/nextcloud/src/main/ets/Utils/FilesDataSource.ets +++ b/nextcloud/src/main/ets/Utils/FilesDataSource.ets @@ -3,19 +3,21 @@ import { DirectoryEntry } from '../webdav/DirEntryParserStateMachine'; import { ListFiles } from '../webdav/Files'; import { Account } from '../Account/Account'; +const SHOW_DIRECTORIES_FIRST = true; + export class FilesDataSource extends BasicDataSource { constructor(account: Account, path: string) { super(); ListFiles(account, path).then(entries => { entries = entries.sort((a: DirectoryEntry, b: DirectoryEntry) => { - // Directories first, then sort by name - if (a.isDirectory && !b.isDirectory) { - return -1; - } else if (!a.isDirectory && b.isDirectory) { - return 1; - } else { - return a.name.localeCompare(b.name); + if (SHOW_DIRECTORIES_FIRST) { + if (a.isDirectory && !b.isDirectory) { + return -1; + } else if (!a.isDirectory && b.isDirectory) { + return 1; + } } + return a.name.localeCompare(b.name); }) this.entries = entries; diff --git a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts index e51e8ec..85c921f 100644 --- a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts +++ b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts @@ -1,9 +1,9 @@ import { DAV_PROPERTY_TYPES, DavPropsType, DavResponse, DavResponseRoot } from './DavTypes'; import { Logger } from '../Utils/Logger'; -const TAG = "Nextcloud-DirEntryParser"; +const TAG = 'webdav/DirEntryParserStateMachine'; -interface DirectoryEntry { +export interface DirectoryEntry { name: string; isDirectory: boolean; mime: string; @@ -23,7 +23,7 @@ enum ParserStatus { Final, }; -class DirEntryParserStateMachine { +export class DirEntryParserStateMachine { private state: ParserStatus = ParserStatus.MultiStatus; private response_root: DavResponseRoot; @@ -193,5 +193,3 @@ class DirEntryParserStateMachine { return entries; } } - -export { DirectoryEntry, DirEntryParserStateMachine }; diff --git a/nextcloud/src/main/ets/webdav/Files.ets b/nextcloud/src/main/ets/webdav/Files.ets index c6f44c2..4bd56cc 100644 --- a/nextcloud/src/main/ets/webdav/Files.ets +++ b/nextcloud/src/main/ets/webdav/Files.ets @@ -5,7 +5,7 @@ import { xml } from '@kit.ArkTS'; import { DirectoryEntry, DirEntryParserStateMachine } from './DirEntryParserStateMachine'; import { Account } from '../Account/Account'; -const TAG = "Nextcloud-Files"; +const TAG = 'webdav/Files'; let session: rcp.Session | null = null; diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets index 49a3d66..7fb2e73 100644 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ b/nextcloud/src/main/ets/webdav/Uploader.ets @@ -2,12 +2,10 @@ import { rcp } from '@kit.RemoteCommunicationKit'; import { NCommon } from '../Common'; import fs from '@ohos.file.fs'; import { Logger } from '../Utils/Logger'; -import { TransferManager, TransferType } from '../TransferManager'; +import { TransferManager, TransferType } from '../SyncEngine/TransferManager'; import { Account } from '../Account/Account'; - -const TAG = "Nextcloud-Upload"; - +const TAG = "webdav/Upload"; function CreateSession(account: Account, extraHeaders?: Map, eventsHandler?: rcp.HttpEventsHandler): rcp.Session { let headers: rcp.RequestHeaders = {}; From 824e4e8b7dec388a587b3009e187e8c5116d3ab6 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Mon, 12 Jan 2026 00:11:11 +0800 Subject: [PATCH 08/16] add account --- .../main/ets/entryability/EntryAbility.ets | 89 +++-- entry/src/main/ets/pages/Index.ets | 1 - entry/src/main/ets/pages/LoginPage.ets | 3 +- entry/src/main/ets/pages/PcMainPage.ets | 317 ++++++++++-------- entry/src/main/ets/pages/SettingsPage.ets | 2 +- nextcloud/src/main/ets/Database/DBManager.ets | 2 +- nextcloud/src/main/ets/Main.ets | 4 + .../main/ets/SyncEngine/SyncFolderManager.ets | 9 + 8 files changed, 231 insertions(+), 196 deletions(-) diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 8680073..2e7ae28 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,6 +1,6 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { AsyncSleep, InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; +import { AsyncSleep, InitialiseNextcloud, Logger } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; import { Notification } from '../utils/NotificationManager'; @@ -28,12 +28,6 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Ability onDestroy'); } - convertToPixelMap(res: Resource) { - const resManager = this.context.resourceManager; - const drawableDesc = resManager?.getDrawableDescriptor(res.id); - return drawableDesc?.getPixelMap(); - } - async onWindowStageCreate(windowStage: window.WindowStage) { while (!this.initCompleted) { Logger.Info(TAG, 'Waiting for initialization to complete...'); @@ -55,48 +49,7 @@ export default class EntryAbility extends UIAbility { if (isPc) { PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_PATH, ''); PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_ALIAS, ''); - - // 构建右键菜单项内容 - let subMenus: Array = []; - let subMenuItemAction: statusBarManager.StatusBarMenuAction = { - abilityName: "EntryAbility" - } - let subMenu: statusBarManager.StatusBarSubMenuItem = { - subTitle: "子菜单项", - menuAction: subMenuItemAction - } - subMenus.push(subMenu); - - let statusBarMenuItems: Array = []; - let menuItem: statusBarManager.StatusBarMenuItem = { - title: "一级菜单项", - // 一级menuAction和subMenu两项不可都缺省 - subMenu: subMenus - }; - statusBarMenuItems.push(menuItem); - - let statusBarGroupMenus: Array = []; - statusBarGroupMenus.push(statusBarMenuItems); - - let img = this.convertToPixelMap($r('app.media.tray_icon')); - - try { - statusBarManager.addToStatusBar(this.context, { - icons: { - black: img, - white: img, - }, - quickOperation: { - title: this.context.resourceManager.getStringSync($r('app.string.app_name').id), - height: 400, - abilityName: 'StatusBarAbility', - }, - statusBarGroupMenu: statusBarGroupMenus, - }); - } catch (e) { - Logger.Error(TAG, `failed to add to status bar: ${JSON.stringify(e)}`); - } - + this.createTrayIcon(); } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); PersistentStorage.persistProp(Constants.PHONE_ACTIVE_ACCOUNT_ID, 0); @@ -130,4 +83,42 @@ export default class EntryAbility extends UIAbility { async onMemoryLevel(level: AbilityConstant.MemoryLevel) { Logger.Info(TAG, `onMemoryLevel: level = ${AbilityConstant.MemoryLevel[level]}`); } + + onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { + Logger.Info(TAG, `want: ${JSON.stringify(want)}, ${JSON.stringify(launchParam)}`); + } + + private convertToPixelMap(res: Resource) { + const resManager = this.context.resourceManager; + const drawableDesc = resManager?.getDrawableDescriptor(res.id); + return drawableDesc?.getPixelMap(); + } + + private createTrayIcon() { + let menuItem: statusBarManager.StatusBarMenuItem = { + title: "Main Window", + menuAction: { + abilityName: "EntryAbility", + } + }; + + let trayIconImage = this.convertToPixelMap($r('app.media.tray_icon')); + + try { + statusBarManager.addToStatusBar(this.context, { + icons: { + black: trayIconImage, + white: trayIconImage, + }, + quickOperation: { + title: this.context.resourceManager.getStringSync($r('app.string.app_name').id), + height: 400, + abilityName: 'StatusBarAbility', + }, + statusBarGroupMenu: [[menuItem]], + }); + } catch (e) { + Logger.Error(TAG, `failed to add to status bar: ${JSON.stringify(e)}`); + } + } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 2ac62d1..4c6fa5c 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -113,7 +113,6 @@ struct Index { .justifyContent(FlexAlign.Center) .height(60) .margin({ top: 10, bottom: 10 }) - } } .tabBar($r("app.string.tab_files")) diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index 5495502..f7c47a4 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -1,4 +1,4 @@ -import { Logger, LoginFlow } from 'nextcloud'; +import { Logger, LoginFlow, Nextcloud } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; import { webview } from '@kit.ArkWeb'; import { ShowAlertDialog, ShowToast } from '../utils/Utils'; @@ -27,6 +27,7 @@ export struct LoginPage { let loginResult = await this.loginFlow.loginPollRepeatedly(); switch (loginResult.kind) { case 'ok': { + await Nextcloud.addAccount(loginResult.account); ShowAlertDialog(this.getUIContext(), "Login Successful", `Welcome, ${loginResult.account.username}!`); this.closeDialog(); break; diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index ff63da6..63f72fc 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -1,12 +1,22 @@ -import { DirectoryEntry, ListFiles, Logger, DownloadFile, Nextcloud, Account } from 'nextcloud'; +import { + Account, + DirectoryEntry, + DownloadFile, + ListFiles, + Logger, + Nextcloud, + TransferManager, + TransferManagerImpl, + TransferTask +} from 'nextcloud'; import { common } from '@kit.AbilityKit'; import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; import { Constants } from '../utils/Common'; import * as CloudSyncApi from '../utils/CloudSyncApi'; import { PermissionManager } from '../utils/PermissionManager'; -import { TransferManager, TransferManagerImpl, TransferTask } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; import { DownloadQueue } from '../utils/DownloadQueue'; +import { SettingsPage } from './SettingsPage'; const TAG = 'PcAbility' const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; @@ -51,6 +61,155 @@ struct PCAbility { CloudSyncApi.updateCustomAlias(this.path, this.alias); } + @Builder + pathOps() { + Column({ space: 16 }) { + Text(`Root Path: ${this.path}`) + + TextInput({ text: this.alias!! }) + + Button('select path') + .onClick(async () => { + let context = this.getUIContext().getHostContext() as common.UIAbilityContext; + let documentPicker = new picker.DocumentViewPicker(context); + let items = await documentPicker.select({ + defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', + maxSelectNumber: 1, + selectMode: picker.DocumentSelectMode.FOLDER, + }); + if (items.length !== 1) { + Logger.Error(TAG, `???`); + return; + } + let path = new fileUri.FileUri(items[0]).path; + this.path = path; + + try { + Logger.Info(TAG, items[0]); + await fileShare.persistPermission([{ + uri: items[0], + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `persist done`); + } catch (e) { + Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); + } + }) + + Button('create root dir') + .onClick(async () => { + let addResult: number = CloudSyncApi.add("Moody 的 Nextcloud", this.path); + Logger.Info(TAG, `Add: ${addResult}`) + }) + + Button('connect') + .onClick(async () => { + CloudSyncApi.connect(this.path); + }) + + Button('disconnect') + .onClick(() => { + CloudSyncApi.disconnect(this.path); + }) + + Button('remove root dir') + .onClick(() => { + CloudSyncApi.remove(this.path); + }) + } + .width('100%') + .height('100%') + .padding(16) + } + + @Builder + pcMainPage() { + Column({ space: 16 }) { + Column({ space: 8 }) { + List() { + ForEach(this.files, (item: DirectoryEntry, index: number) => { + ListItem() { + Text(`${index}: ${item.name} --- ${item.isDirectory ? 'dir' : item.mime}`) + } + .clip(true) + }) + } + .width('100%') + .height(150) + + Button('reload') + .onClick(async () => { + this.files = await ListFiles(this.account, '/'); + }) + + Button('sync all') + .onClick(async () => { + Logger.Info(TAG, `sync all`); + this.downloadQueue.addRootDir(this.account); + while (this.downloadQueue.hasPendingItem()) { + let next = await this.downloadQueue.getNextDownloadFile(); + if (next === undefined) { + break; + } + Logger.Info(TAG, next.relativePath); + if (next.isDirectory) { + try { + fileIo.mkdirSync(this.path + '/' + next.relativePath); + } catch (e) { + } + } else { + await DownloadFile(this.account, next.relativePath, this.path + '/' + next.relativePath); + } + Logger.Info(TAG, next.relativePath); + } + Logger.Info(TAG, `download completed`); + }) + } + .padding(8) + .width('100%') + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + + Column({ space: 8 }) { + Text('download progress') + + List() { + LazyForEach(this.transferManager.tasks, (item: TransferTask) => { + ListItem() { + TransferProgressItem({ + transferTask: item + }) + } + }) + } + .width('100%') + .height(250) + } + + Column({ space: 8 }) { + Text('Operation History:') + List() { + ForEach(this.opHistory, (item: CloudSyncApi.FileChangeInfo, index) => { + ListItem() { + Row() { + Text(`${index + 1}. `) + Text( + `: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}` + ) + } + } + }) + } + .width('100%') + .height(250) + } + .padding(8) + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + } + .width('100%') + .height('100%') + .padding(16) + } + async aboutToAppear(): Promise { Logger.Info(TAG, `pcAbility aboutToAppear`); await PermissionManager.RequestPermission(this.getUIContext(), ["ohos.permission.FILE_ACCESS_PERSIST"]) @@ -90,152 +249,24 @@ struct PCAbility { } build() { - Row({ space: 16 }) { - Column({ space: 16 }) { - Text(`Root Path: ${this.path}`) - - TextInput({ text: this.alias!! }) - - Button('select path') - .onClick(async () => { - let context = this.getUIContext().getHostContext() as common.UIAbilityContext; - let documentPicker = new picker.DocumentViewPicker(context); - let items = await documentPicker.select({ - defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', - maxSelectNumber: 1, - selectMode: picker.DocumentSelectMode.FOLDER, - }); - if (items.length !== 1) { - Logger.Error(TAG, `???`); - return; - } - let path = new fileUri.FileUri(items[0]).path; - this.path = path; - - try { - Logger.Info(TAG, items[0]); - await fileShare.persistPermission([{ - uri: items[0], - operationMode: OPERATION_MODES - }]); - Logger.Info(TAG, `persist done`); - } catch (e) { - Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); - } - }) - - Button('create root dir') - .onClick(async () => { - let addResult: number = CloudSyncApi.add("Moody 的 Nextcloud", this.path); - Logger.Info(TAG, `Add: ${addResult}`) - }) - - Button('connect') - .onClick(async () => { - CloudSyncApi.connect(this.path); - }) - - Button('disconnect') - .onClick(() => { - CloudSyncApi.disconnect(this.path); - }) - - Button('remove root dir') - .onClick(() => { - CloudSyncApi.remove(this.path); - }) + Tabs({ barPosition: BarPosition.Start }) { + TabContent() { + this.pcMainPage() } - .padding(8) - .width(300) - .backgroundColor($r('sys.color.glass_material_outline_secondary')) - - Column({ space: 16 }) { - Column({ space: 8 }) { - List() { - ForEach(this.files, (item: DirectoryEntry, index: number) => { - ListItem() { - Text(`${index}: ${item.name} --- ${item.isDirectory ? 'dir' : item.mime}`) - } - .clip(true) - }) - } - .width('100%') - .height(150) - - Button('reload') - .onClick(async () => { - this.files = await ListFiles(this.account, '/'); - }) - - Button('sync all') - .onClick(async () => { - Logger.Info(TAG, `sync all`); - this.downloadQueue.addRootDir(this.account); - while (this.downloadQueue.hasPendingItem()) { - let next = await this.downloadQueue.getNextDownloadFile(); - if (next === undefined) { - break; - } - Logger.Info(TAG, next.relativePath); - if (next.isDirectory) { - try { - fileIo.mkdirSync(this.path + '/' + next.relativePath); - } catch (e) { - } - } else { - await DownloadFile(this.account, next.relativePath, this.path + '/' + next.relativePath); - } - Logger.Info(TAG, next.relativePath); - } - Logger.Info(TAG, `download completed`); - }) - } - .padding(8) - .width('100%') - .backgroundColor($r('sys.color.glass_material_outline_secondary')) - - Column({ space: 8 }) { - Text('download progress') + .tabBar($r('app.string.tab_files')) - List() { - LazyForEach(this.transferManager.tasks, (item: TransferTask) => { - ListItem() { - TransferProgressItem({ - transferTask: item - }) - } - }) - } - .width('100%') - .height(250) - } + TabContent() { + this.pathOps() + } + .tabBar('Paths') - Column({ space: 8 }) { - Text('Operation History:') - List() { - ForEach(this.opHistory, (item: CloudSyncApi.FileChangeInfo, index) => { - ListItem() { - Row() { - Text(`${index + 1}. `) - Text( - `: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}` - ) - } - } - }) - } + TabContent() { + SettingsPage({ currentAccount: this.account }) .width('100%') - .height(250) - } - .padding(8) - .backgroundColor($r('sys.color.glass_material_outline_secondary')) + .height('100%') } - .width(600) - .padding(8) - .border({ color: Color.Blue, width: 2 }) + .tabBar($r('app.string.tab_settings')) } - .width('100%') - .height('100%') - .margin(16) + .vertical(true) } } diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 9a5d65c..1294f40 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -155,7 +155,7 @@ export struct SettingsPage { .margin({ top: 16, bottom: 16 }) Button('DROP DATABASE') - .buttonStyle(ButtonStyleMode.EMPHASIZED) + .backgroundColor(Color.Red) .onClick(async () => { await Nextcloud.recreateDatabase(); try { diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index c791bcf..2030c9e 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -42,7 +42,7 @@ const TABLES: DBTableDescriptor[] = [ ]; export class DBManager { - store: relationalStore.RdbStore | undefined = undefined; + private store: relationalStore.RdbStore | undefined = undefined; static generateTableCreationScript(tableName: string, columnDesc: Record): string { let foreignKeyDefs: string[] = []; diff --git a/nextcloud/src/main/ets/Main.ets b/nextcloud/src/main/ets/Main.ets index 5494917..f0a2b20 100644 --- a/nextcloud/src/main/ets/Main.ets +++ b/nextcloud/src/main/ets/Main.ets @@ -34,6 +34,10 @@ class NextcloudImpl { return this.syncFolderManager.hasAccounts(); } + public async addAccount(account: Account): Promise { + await this.syncFolderManager.addAccount(account); + } + public getAccountById(accountId: number): Account { return this.syncFolderManager.getAccountById(accountId); } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 59a9966..3c76d0a 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -36,6 +36,15 @@ export class SyncFolderManager { return account; } + public getAllAccounts(): Account[] { + let values = this.accounts.values(); + return Array.from(values); + } + + public async addAccount(account: Account): Promise { + await this.dbManager.storeItem(account.dbAccount); + } + private async loadSyncFolders() { let dbFolders = await this.dbManager.getItems(DBLocalSyncFolderTable); for (let dbFolder of dbFolders) { From fddb1be4662f36da8ce0ca1beb0cd2cf517deb34 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sat, 17 Jan 2026 01:37:40 +0800 Subject: [PATCH 09/16] sync configuration CRUD --- entry/patch.json | 2 +- .../main/ets/entryability/EntryAbility.ets | 20 +- entry/src/main/ets/pages/PcMainPage.ets | 278 +++++++++++------- entry/src/main/ets/utils/Common.ets | 4 - nextcloud/src/main/ets/Account/Account.ets | 8 +- nextcloud/src/main/ets/Database/DBManager.ets | 95 ++++-- .../Database/{Types => DbTypes}/DBAccount.ets | 6 +- .../ets/Database/DbTypes/DBLocalFolder.ets | 55 ++++ .../{Types => DbTypes}/DBRemoteFolder.ets | 12 +- .../Database/DbTypes/DBSyncConfiguration.ets | 63 ++++ .../ets/Database/Types/DBLocalSyncFolder.ets | 75 ----- .../main/ets/Database/Types/LocalFolder.ets | 21 ++ .../main/ets/Database/Types/RemoteFolder.ets | 18 ++ .../ets/Database/Types/SyncConfiguration.ets | 19 ++ nextcloud/src/main/ets/Index.ets | 4 + nextcloud/src/main/ets/Main.ets | 101 +++++-- .../src/main/ets/SyncEngine/SyncFolder.ets | 16 - .../main/ets/SyncEngine/SyncFolderManager.ets | 143 +++++---- 18 files changed, 621 insertions(+), 319 deletions(-) rename nextcloud/src/main/ets/Database/{Types => DbTypes}/DBAccount.ets (92%) create mode 100644 nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets rename nextcloud/src/main/ets/Database/{Types => DbTypes}/DBRemoteFolder.ets (92%) create mode 100644 nextcloud/src/main/ets/Database/DbTypes/DBSyncConfiguration.ets delete mode 100644 nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets create mode 100644 nextcloud/src/main/ets/Database/Types/LocalFolder.ets create mode 100644 nextcloud/src/main/ets/Database/Types/RemoteFolder.ets create mode 100644 nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets delete mode 100644 nextcloud/src/main/ets/SyncEngine/SyncFolder.ets diff --git a/entry/patch.json b/entry/patch.json index 414881b..cdb9276 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2000866, + "patchVersionCode": 2000954, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 2e7ae28..ea9e812 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -33,7 +33,7 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Waiting for initialization to complete...'); await AsyncSleep(100); } - Logger.Info(TAG, 'Main window is created, set main page for this ability'); + Logger.Info(TAG, 'Initialization completed, proceeding to create main window.'); let context = this.context; GlobalStorage.SandboxBasePath = context.filesDir; @@ -47,8 +47,6 @@ export default class EntryAbility extends UIAbility { } await windowStage.loadContent(page); if (isPc) { - PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_PATH, ''); - PersistentStorage.persistProp(Constants.PC_CLOUD_DISK_ALIAS, ''); this.createTrayIcon(); } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); @@ -63,6 +61,9 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Main window created.'); } + /** + * @throws + */ async onPrepareToTerminateAsync(): Promise { await this.context.hideAbility(); return true; @@ -88,23 +89,26 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, `want: ${JSON.stringify(want)}, ${JSON.stringify(launchParam)}`); } - private convertToPixelMap(res: Resource) { + /** + * Convert Resource to PixelMap + * @throws + */ + private convertToPixelMap(res: Resource): PixelMap { const resManager = this.context.resourceManager; const drawableDesc = resManager?.getDrawableDescriptor(res.id); - return drawableDesc?.getPixelMap(); + return drawableDesc.getPixelMap(); } private createTrayIcon() { - let menuItem: statusBarManager.StatusBarMenuItem = { + const menuItem: statusBarManager.StatusBarMenuItem = { title: "Main Window", menuAction: { abilityName: "EntryAbility", } }; - let trayIconImage = this.convertToPixelMap($r('app.media.tray_icon')); - try { + let trayIconImage = this.convertToPixelMap($r('app.media.tray_icon')); statusBarManager.addToStatusBar(this.context, { icons: { black: trayIconImage, diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 63f72fc..0053615 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -5,13 +5,14 @@ import { ListFiles, Logger, Nextcloud, + SyncConfiguration, + SyncFolderManager, TransferManager, TransferManagerImpl, - TransferTask + TransferTask, } from 'nextcloud'; import { common } from '@kit.AbilityKit'; import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; -import { Constants } from '../utils/Common'; import * as CloudSyncApi from '../utils/CloudSyncApi'; import { PermissionManager } from '../utils/PermissionManager'; import { LengthMetrics } from '@kit.ArkUI'; @@ -21,9 +22,9 @@ import { SettingsPage } from './SettingsPage'; const TAG = 'PcAbility' const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; -@Component +@ComponentV2 struct TransferProgressItem { - @ObjectLink transferTask: TransferTask; + @Require @Param transferTask: TransferTask; build() { Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center, space: { main: LengthMetrics.vp(8) } }) { @@ -43,79 +44,105 @@ struct TransferProgressItem { } } - -@Entry -@Component -struct PCAbility { - @StorageLink(Constants.PC_CLOUD_DISK_PATH) private path: string = ''; - @StorageLink(Constants.PC_CLOUD_DISK_ALIAS) @Watch('onAliasChanged') private alias: string = 'Moody 的 Nextcloud'; - @State private opHistory: CloudSyncApi.FileChangeInfo[] = []; - @State private files: DirectoryEntry[] = []; - @StorageLink(Constants.PHONE_ACTIVE_ACCOUNT_ID) private readonly activeAccountId: number = 0; - @State private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); - @State private transferManager: TransferManagerImpl = TransferManager; +@ComponentV2 +struct SyncConfigurationRow { + @Require @Param readonly syncFolderManager: SyncFolderManager; + @Require @Param readonly configuration: SyncConfiguration; + @Require @Param readonly account: Account; private downloadQueue: DownloadQueue = new DownloadQueue(); - private pendingMoveTo: Map = new Map(); - - onAliasChanged(): void { - CloudSyncApi.updateCustomAlias(this.path, this.alias); - } - @Builder - pathOps() { - Column({ space: 16 }) { - Text(`Root Path: ${this.path}`) + build() { + Row({ space: 8 }) { + Text(`${this.configuration.id} : ${this.configuration.localFolder.localPath} <=> ${this.configuration.remoteFolder.remotePath}`) - TextInput({ text: this.alias!! }) + Blank() + .flexGrow(1) - Button('select path') + Button('connect') .onClick(async () => { - let context = this.getUIContext().getHostContext() as common.UIAbilityContext; - let documentPicker = new picker.DocumentViewPicker(context); - let items = await documentPicker.select({ - defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', - maxSelectNumber: 1, - selectMode: picker.DocumentSelectMode.FOLDER, - }); - if (items.length !== 1) { - Logger.Error(TAG, `???`); - return; - } - let path = new fileUri.FileUri(items[0]).path; - this.path = path; - - try { - Logger.Info(TAG, items[0]); - await fileShare.persistPermission([{ - uri: items[0], - operationMode: OPERATION_MODES - }]); - Logger.Info(TAG, `persist done`); - } catch (e) { - Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); - } + CloudSyncApi.connect(this.configuration.localFolder.localPath); }) - Button('create root dir') - .onClick(async () => { - let addResult: number = CloudSyncApi.add("Moody 的 Nextcloud", this.path); - Logger.Info(TAG, `Add: ${addResult}`) + Button('disconnect') + .onClick(() => { + CloudSyncApi.disconnect(this.configuration.localFolder.localPath); }) - Button('connect') + Button('sync all') + .onClick(() => this.doDownloadAll()) + + Button('DELETE') .onClick(async () => { - CloudSyncApi.connect(this.path); + CloudSyncApi.disconnect(this.configuration.localFolder.localPath); + CloudSyncApi.remove(this.configuration.localFolder.localPath); + await this.syncFolderManager.removeSyncConfiguration(this.configuration); }) + } + .width('100%') + .height(50) + .padding({ left: 8, right: 8 }) + .border({ width: 1, color: Color.Green }) + } - Button('disconnect') - .onClick(() => { - CloudSyncApi.disconnect(this.path); - }) + private async doDownloadAll() { + Logger.Info(TAG, `sync all`); + this.downloadQueue.addRootDir(this.account); + while (this.downloadQueue.hasPendingItem()) { + let next = await this.downloadQueue.getNextDownloadFile(); + if (next === undefined) { + break; + } + Logger.Info(TAG, next.relativePath); + if (next.isDirectory) { + try { + fileIo.mkdirSync(this.configuration.localFolder.localPath + '/' + next.relativePath); + } catch (e) { + } + } else { + await DownloadFile(this.account, next.relativePath, this.configuration.localFolder.localPath + '/' + next.relativePath); + } + Logger.Info(TAG, next.relativePath); + } + Logger.Info(TAG, `download completed`); + } +} - Button('remove root dir') - .onClick(() => { - CloudSyncApi.remove(this.path); +@Entry +@ComponentV2 +struct PCAbility { + @Local private readonly activeAccountId: number = 0; // TODO: get from PersistentStorage + @Local private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); + @Local private opHistory: CloudSyncApi.FileChangeInfo[] = []; + @Local private files: DirectoryEntry[] = []; + @Local private syncFolderManager: SyncFolderManager = Nextcloud.getSyncFolderManager(); + @Local private transferManager: TransferManagerImpl = TransferManager; + private pendingMoveTo: Map = new Map(); + + @Builder + pathOps() { + Column() { + List({ space: 4 }) { + ListItem() { + Button('+') + .onClick(async () => { + await this.doAddNewSyncConfiguration(); + }) + .width('100%') + } + + ForEach(Array.from(this.syncFolderManager.allSyncConfigurations), (conf: [number, SyncConfiguration], index: number) => { + ListItem() { + SyncConfigurationRow({ + account: this.account, + syncFolderManager: this.syncFolderManager, + configuration: conf[1], + }) + } + .clip(true) }) + } + .width('100%') + .height('100%') } .width('100%') .height('100%') @@ -142,28 +169,6 @@ struct PCAbility { this.files = await ListFiles(this.account, '/'); }) - Button('sync all') - .onClick(async () => { - Logger.Info(TAG, `sync all`); - this.downloadQueue.addRootDir(this.account); - while (this.downloadQueue.hasPendingItem()) { - let next = await this.downloadQueue.getNextDownloadFile(); - if (next === undefined) { - break; - } - Logger.Info(TAG, next.relativePath); - if (next.isDirectory) { - try { - fileIo.mkdirSync(this.path + '/' + next.relativePath); - } catch (e) { - } - } else { - await DownloadFile(this.account, next.relativePath, this.path + '/' + next.relativePath); - } - Logger.Info(TAG, next.relativePath); - } - Logger.Info(TAG, `download completed`); - }) } .padding(8) .width('100%') @@ -213,52 +218,45 @@ struct PCAbility { async aboutToAppear(): Promise { Logger.Info(TAG, `pcAbility aboutToAppear`); await PermissionManager.RequestPermission(this.getUIContext(), ["ohos.permission.FILE_ACCESS_PERSIST"]) - if (this.path) { - CloudSyncApi.connect(this.path); + for (let syncConfiguration of this.syncFolderManager.allSyncConfigurations.values()) { + Logger.Info(TAG, `configuration: ${syncConfiguration.id} --- ${syncConfiguration.localFolder.localPath} <=> ${syncConfiguration.remoteFolder.remotePath}`); + CloudSyncApi.connect(syncConfiguration.localFolder.localPath); + + try { + await fileShare.activatePermission([{ + uri: fileUri.getUriFromPath(syncConfiguration.localFolder.localPath), + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `activate permission done for '${syncConfiguration.localFolder.localPath}'`); + } catch (e) { + Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); + } } + CloudSyncApi.registerFileChangeMonitor((info: CloudSyncApi.FileChangeInfo) => { - if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_FROM) { - this.pendingMoveTo.set(info.fileId, info.relativePath); - } else if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_TO) { - let moveFrom = this.pendingMoveTo.get(info.fileId); - if (moveFrom === undefined) { - Logger.Error(TAG, `got a MoveTo without a MoveFrom`); - } else { - Logger.Info(TAG, `move: ${moveFrom} -> ${info.relativePath}`); - this.pendingMoveTo.delete(info.fileId); - } - } - Logger.Info(TAG, `${CloudSyncApi.FileChangeOperationType[info.operationType]} ::: '${info.relativePath}'`); - this.opHistory = [info, ...this.opHistory]; + this.onFileChanged(info) }); - try { - await fileShare.activatePermission([{ - uri: fileUri.getUriFromPath(this.path), - operationMode: OPERATION_MODES - }]); - Logger.Info(TAG, `activate permission done`); - } catch (e) { - Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); - } this.files = await ListFiles(this.account, '/'); } aboutToDisappear(): void { - CloudSyncApi.disconnect(this.path); + for (let syncConfiguration of this.syncFolderManager.allSyncConfigurations.values()) { + CloudSyncApi.disconnect(syncConfiguration.localFolder.localPath); + } } build() { Tabs({ barPosition: BarPosition.Start }) { TabContent() { - this.pcMainPage() + this.pathOps() } - .tabBar($r('app.string.tab_files')) + .tabBar('Paths') TabContent() { - this.pathOps() + this.pcMainPage() } - .tabBar('Paths') + .tabBar($r('app.string.tab_files')) TabContent() { SettingsPage({ currentAccount: this.account }) @@ -269,4 +267,56 @@ struct PCAbility { } .vertical(true) } + + private async doAddNewSyncConfiguration() { + let context = this.getUIContext().getHostContext() as common.UIAbilityContext; + let documentPicker = new picker.DocumentViewPicker(context); + let items = await documentPicker.select({ + defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', + maxSelectNumber: 1, + selectMode: picker.DocumentSelectMode.FOLDER, + }); + if (items.length !== 1) { + Logger.Error(TAG, `???`); + return; + } + let path = new fileUri.FileUri(items[0]).path; + + try { + Logger.Info(TAG, items[0]); + await fileShare.persistPermission([{ + uri: items[0], + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `persist done`); + } catch (e) { + Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); + } + + let localPathAlias = 'LocalPathAlias_' + Date.now(); + let localFolder = await this.syncFolderManager.addLocalFolder(path, localPathAlias); + Logger.Info(TAG, `added local folder: ${JSON.stringify(localFolder)}`); + let remoteFolder = await this.syncFolderManager.addRemoteFolder(this.account, '/', 'RemotePathAlias_' + Date.now()); + Logger.Info(TAG, `added remote folder: ${JSON.stringify(remoteFolder)}`); + let syncConfig = await this.syncFolderManager.addSyncConfiguration(localFolder, remoteFolder); + Logger.Info(TAG, `added sync configuration: ${JSON.stringify(syncConfig)}`); + let addResult: number = CloudSyncApi.add(localPathAlias, path); + Logger.Info(TAG, `Add: ${addResult}`) + } + + private async onFileChanged(info: CloudSyncApi.FileChangeInfo): Promise { + if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_FROM) { + this.pendingMoveTo.set(info.fileId, info.relativePath); + } else if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_TO) { + let moveFrom = this.pendingMoveTo.get(info.fileId); + if (moveFrom === undefined) { + Logger.Error(TAG, `got a MoveTo without a MoveFrom`); + } else { + Logger.Info(TAG, `move: ${moveFrom} -> ${info.relativePath}`); + this.pendingMoveTo.delete(info.fileId); + } + } + Logger.Info(TAG, `${CloudSyncApi.FileChangeOperationType[info.operationType]} ::: '${info.relativePath}'`); + this.opHistory = [info, ...this.opHistory]; + } } diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index 079fc44..9244459 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -8,8 +8,4 @@ export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; export const PHONE_ACTIVE_ACCOUNT_ID = 'Phone.ActiveAccountId'; - - export const PC_CLOUD_DISK_PATH = 'PC.CloudDisk.Path'; - - export const PC_CLOUD_DISK_ALIAS = 'PC.CloudDisk.Alias'; } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index 0bb1eef..410a2f4 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -1,17 +1,21 @@ -import { DBAccount } from '../Database/Types/DBAccount'; +import { DBAccount } from '../Database/DbTypes/DBAccount'; import { Base64Encode } from '../Utils/CommonUtils'; import { Logger } from '../Utils/Logger'; const TAG = 'Account/Account'; export class Account { - dbAccount: DBAccount; + public dbAccount: DBAccount; constructor(dbAccount: DBAccount) { Logger.Info(TAG, `constructor: enter`); this.dbAccount = dbAccount; } + public get id(): number { + return this.dbAccount.id; + } + public get username(): string { return this.dbAccount.username; } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index 2030c9e..b122b16 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -1,13 +1,14 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../Utils/Logger'; -import { DBAccountTableInfo } from './Types/DBAccount'; -import { DBLocalSyncFolderTableInfo } from './Types/DBLocalSyncFolder'; -import { DBRemoteFolderTable } from './Types/DBRemoteFolder'; +import { DBAccountTableInfo } from './DbTypes/DBAccount'; +import { DBLocalSyncFolderTable } from './DbTypes/DBLocalFolder'; +import { DBRemoteFolderTable } from './DbTypes/DBRemoteFolder'; import { DEBUG } from '../../../../BuildProfile'; +import { DBSyncConfigurationTable } from './DbTypes/DBSyncConfiguration'; const TAG = 'Database/DBManager'; -const CAN_RECREATE_DATABASE = false; +const CAN_RECREATE_DATABASE = true; interface ForeignKeyDescriptor { target: DBTableDescriptor; @@ -32,13 +33,14 @@ export interface DBTableDescriptor { export interface DBItem { getTableName(): string; - toBucket(): relationalStore.ValuesBucket; + toBucket(isCreate: boolean): relationalStore.ValuesBucket; } const TABLES: DBTableDescriptor[] = [ DBAccountTableInfo, DBRemoteFolderTable, - DBLocalSyncFolderTableInfo, + DBLocalSyncFolderTable, + DBSyncConfigurationTable, ]; export class DBManager { @@ -179,25 +181,64 @@ export class DBManager { return result; } - async storeItem(object: DBItem): Promise { - Logger.Info(TAG, 'storeObject: enter'); + async getItemById(id: number, column: ColumnDescriptor, factory: DBTableDescriptor): Promise { + Logger.Info(TAG, 'getItemById: enter'); if (!this.store) { - Logger.Error(TAG, `storeObject: store is undefined`); - return; + Logger.Error(TAG, `getItemById: store is undefined`); + return null; } - let bucket = object.toBucket(); + let predicate = new relationalStore.RdbPredicates(factory.tableName); + predicate.equalTo(Object.keys(factory.columns).find(key => factory.columns[key] === column)!, id); + try { - let row = await this.store.insert(object.getTableName(), bucket); - if (!row) { - Logger.Error(TAG, `storeObject: failed to insert account, no row returned`); - return; + let resultSet = await this.store.query(predicate); + if (resultSet.rowCount === 0) { + Logger.Warn(TAG, `getItemById: no item found with id=${id}`); + return null; + } + if (resultSet.goToNextRow()) { + let bucket = resultSet.getRow(); + let item = factory.createItemFromBucket(bucket); + Logger.Info(TAG, `getItemById: completed, found item with id=${id}`); + return item; } } catch (error) { - Logger.Error(TAG, `storeObject: failed to insert account: ${JSON.stringify(error)}`); + Logger.Error(TAG, `getItemById: failed to read result set: ${JSON.stringify(error)}`); + return null; + } + + Logger.Info(TAG, `getItemById: completed, no item found with id=${id}`); + return null; + } + + async deleteItemById(id: number, object: DBItem): Promise { + Logger.Info(TAG, 'deleteItemById: enter'); + if (!this.store) { + Logger.Error(TAG, `deleteItemById: store is undefined`); return; } - Logger.Info(TAG, 'storeObject: completed'); + + let predicate = new relationalStore.RdbPredicates(object.getTableName()); + let bucket = object.toBucket(false); + let idColumnName = Object.keys(bucket).find(key => bucket[key] === id); + predicate.equalTo(idColumnName!, id); + + try { + let deletedCount = await this.store.delete(predicate); + Logger.Info(TAG, `deleteItemById: completed, deleted ${deletedCount} rows from table ${object.getTableName()}`); + } catch (error) { + Logger.Error(TAG, `deleteItemById: failed to delete from table ${object.getTableName()}: ${JSON.stringify(error)}`); + } + + } + + async createItem(object: DBItem): Promise { + return this.doStoreItem(object, true); + } + + async updateItem(object: DBItem): Promise { + return this.doStoreItem(object, false); } async dropDatabase() { @@ -206,7 +247,7 @@ export class DBManager { return; } - Logger.Warn(TAG, `dropAllTables: enter, SHOULD_RECREATE_DATABASE=${CAN_RECREATE_DATABASE}, DEBUG=${DEBUG}`); + Logger.Warn(TAG, `dropAllTables: enter, CAN_RECREATE_DATABASE=${CAN_RECREATE_DATABASE}, DEBUG=${DEBUG}`); if (CAN_RECREATE_DATABASE && DEBUG) { Logger.Warn(TAG, 'upgradeDatabase: recreating database as per ALWAYS_RECREATE_DATABASE flag'); try { @@ -240,4 +281,22 @@ export class DBManager { Logger.Warn(TAG, 'dropAllTables: operation is disabled'); } } + + private async doStoreItem(object: DBItem, isCreate: boolean): Promise { + Logger.Info(TAG, `doStoreItem: enter, isCreate=${isCreate}`); + if (!this.store) { + Logger.Error(TAG, `doStoreItem: store is undefined`); + return; + } + + let bucket = object.toBucket(isCreate); + try { + let rowId = await this.store.insert(object.getTableName(), bucket); + Logger.Info(TAG, `doStoreItem: completed, rowId: ${rowId}`); + return rowId; + } catch (error) { + Logger.Error(TAG, `doStoreItem: failed to insert to table ${object.getTableName()}: ${JSON.stringify(error)}`); + return; + } + } } diff --git a/nextcloud/src/main/ets/Database/Types/DBAccount.ets b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets similarity index 92% rename from nextcloud/src/main/ets/Database/Types/DBAccount.ets rename to nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets index 2624d3f..fcda953 100644 --- a/nextcloud/src/main/ets/Database/Types/DBAccount.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets @@ -23,9 +23,11 @@ export class DBAccount implements DBItem { return DBAccount.DB_TABLE_NAME; } - public toBucket(): relationalStore.ValuesBucket { + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { let bucket: relationalStore.ValuesBucket = {}; - bucket['account_id'] = this.id; + if (isCreate === false) { + bucket['account_id'] = this.id; + } bucket['username'] = this.username; bucket['password'] = this.password; bucket['hostname'] = this.hostname; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets new file mode 100644 index 0000000..16da938 --- /dev/null +++ b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets @@ -0,0 +1,55 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { DBItem, DBTableDescriptor } from '../DBManager'; + +const TAG = 'Database/DBSyncFolder'; + +export class DBLocalSyncFolder implements DBItem { + static readonly DB_TABLE_NAME: string = 'LocalFolders'; + id: number; + localPath: string; + alias: string; + + constructor(id: number, localPath: string, alias: string) { + Logger.Info(TAG, `constructor: enter, id=${id}, localPath=${localPath}, alias=${alias}`); + this.id = id; + this.localPath = localPath; + this.alias = alias; + } + + public getTableName(): string { + return DBLocalSyncFolder.DB_TABLE_NAME; + } + + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + if (isCreate === false) { + bucket['folder_id'] = this.id; + } + bucket['local_path'] = this.localPath; + bucket['alias'] = this.alias; + return bucket; + } + + public toString(): string { + return `DBLocalSyncFolder{id=${this.id}, localPath=${this.localPath}, alias=${this.alias}}`; + } +} + +type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias'; + +export const DBLocalSyncFolderTable: DBTableDescriptor = { + tableName: DBLocalSyncFolder.DB_TABLE_NAME, + columns: { + 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'local_path': { type: 'TEXT', attrs: 'NOT NULL' }, + 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, + }, + + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalSyncFolder { + let id = bucket['folder_id'] as number; + let localPath = bucket['local_path'] as string; + let alias = bucket['alias'] as string; + return new DBLocalSyncFolder(id, localPath, alias); + } +}; diff --git a/nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets similarity index 92% rename from nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets rename to nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets index b198b51..ce20ab3 100644 --- a/nextcloud/src/main/ets/Database/Types/DBRemoteFolder.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets @@ -24,9 +24,11 @@ export class DBRemoteFolder implements DBItem { return DBRemoteFolder.DB_TABLE_NAME; } - public toBucket(): relationalStore.ValuesBucket { + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { let bucket: relationalStore.ValuesBucket = {}; - bucket['folder_id'] = this.id; + if (isCreate === false) { + bucket['folder_id'] = this.id; + } bucket['account_id'] = this.accountId; bucket['remote_path'] = this.remotePath; bucket['alias'] = this.alias; @@ -34,14 +36,13 @@ export class DBRemoteFolder implements DBItem { } } -type DBRemoteFolderColumnNames = 'folder_id' | 'remote_path' | 'alias' | 'account_id'; +type DBRemoteFolderColumnNames = 'folder_id' | 'remote_path' | 'account_id' | 'alias'; export const DBRemoteFolderTable: DBTableDescriptor = { tableName: DBRemoteFolder.DB_TABLE_NAME, columns: { 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, 'remote_path': { type: 'TEXT', attrs: 'NOT NULL' }, - 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, 'account_id': { type: 'INTEGER', attrs: 'NOT NULL', @@ -50,12 +51,13 @@ export const DBRemoteFolderTable: DBTableDescriptor = { + tableName: DBSyncConfiguration.DB_TABLE_NAME, + columns: { + 'id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'local_folder_id': { + type: 'INTEGER', attrs: 'NOT NULL', + foreignKey: { + target: DBLocalSyncFolderTable, + targetColumn: DBLocalSyncFolderTable.columns.folder_id, + } + }, + 'remote_folder_id': { + type: 'INTEGER', attrs: 'NOT NULL', foreignKey: { + target: DBRemoteFolderTable, + targetColumn: DBRemoteFolderTable.columns.folder_id, + } + }, + }, + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBSyncConfiguration { + let id = bucket['id'] as number; + let localFolderId = bucket['local_folder_id'] as number; + let remoteFolderId = bucket['remote_folder_id'] as number; + return new DBSyncConfiguration(id, localFolderId, remoteFolderId); + }, +}; diff --git a/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets b/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets deleted file mode 100644 index e96ad6e..0000000 --- a/nextcloud/src/main/ets/Database/Types/DBLocalSyncFolder.ets +++ /dev/null @@ -1,75 +0,0 @@ -import { relationalStore } from '@kit.ArkData'; -import { Logger } from '../../Utils/Logger'; -import { DBItem, DBTableDescriptor } from '../DBManager'; -import { DBAccountTableInfo } from './DBAccount'; -import { DBRemoteFolderTable } from './DBRemoteFolder'; - -const TAG = 'Database/DBSyncFolder'; - -export class DBLocalSyncFolder implements DBItem { - static readonly DB_TABLE_NAME: string = 'LocalSyncFolders'; - id: number; - localPath: string; - alias: string; - accountId: number; - remoteFolderId: number; - - constructor(id: number, localPath: string, alias: string, account_id: number, remote_folder_id: number) { - Logger.Info(TAG, `constructor: enter, id=${id}, localPath=${localPath}, alias=${alias}, account_id=${account_id}, remote_folder_id=${remote_folder_id}`); - this.id = id; - this.localPath = localPath; - this.alias = alias; - this.accountId = account_id; - this.remoteFolderId = remote_folder_id; - } - - public getTableName(): string { - return DBLocalSyncFolder.DB_TABLE_NAME; - } - - public toBucket(): relationalStore.ValuesBucket { - let bucket: relationalStore.ValuesBucket = {}; - bucket['folder_id'] = this.id; - bucket['local_path'] = this.localPath; - bucket['alias'] = this.alias; - bucket['account_id'] = this.accountId; - bucket['remote_folder_id'] = this.remoteFolderId; - return bucket; - } -} - -type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'account_id' | 'remote_folder_id'; - -export const DBLocalSyncFolderTableInfo: DBTableDescriptor = { - tableName: DBLocalSyncFolder.DB_TABLE_NAME, - columns: { - 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, - 'account_id': { - type: 'INTEGER', - attrs: 'NOT NULL', - foreignKey: { - target: DBAccountTableInfo, - targetColumn: DBAccountTableInfo.columns.account_id, - }, - }, - 'local_path': { type: 'TEXT', attrs: 'NOT NULL' }, - 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, - 'remote_folder_id': { - type: 'INTEGER', - attrs: 'NOT NULL', - foreignKey: { - target: DBRemoteFolderTable, - targetColumn: DBRemoteFolderTable.columns.folder_id, - }, - }, - }, - - createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalSyncFolder { - let id = bucket['folder_id'] as number; - let localPath = bucket['local_path'] as string; - let alias = bucket['alias'] as string; - let account_id = bucket['account_id'] as number; - let remote_folder_id = bucket['remote_folder_id'] as number; - return new DBLocalSyncFolder(id, localPath, alias, account_id, remote_folder_id); - } -}; diff --git a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets new file mode 100644 index 0000000..3f79b83 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets @@ -0,0 +1,21 @@ +import { DBLocalSyncFolder } from "../DbTypes/DBLocalFolder"; + +export class LocalFolder { + private dbLocalFolder_: DBLocalSyncFolder; + + constructor(dbLocalFolder: DBLocalSyncFolder) { + this.dbLocalFolder_ = dbLocalFolder; + } + + public get id(): number { + return this.dbLocalFolder_.id; + } + + public get localPath(): string { + return this.dbLocalFolder_.localPath; + } + + public get alias(): string { + return this.dbLocalFolder_.alias; + } +} diff --git a/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets new file mode 100644 index 0000000..e59f61d --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets @@ -0,0 +1,18 @@ +import { Account } from '../../Account/Account'; +import { DBRemoteFolder } from '../DbTypes/DBRemoteFolder'; + +export class RemoteFolder { + private dbRemoteFolder_: DBRemoteFolder; + + constructor(dbRemoteFolder: DBRemoteFolder) { + this.dbRemoteFolder_ = dbRemoteFolder; + } + + public get id(): number { + return this.dbRemoteFolder_.id; + } + + public get remotePath(): string { + return this.dbRemoteFolder_.remotePath; + } +} diff --git a/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets b/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets new file mode 100644 index 0000000..6798298 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets @@ -0,0 +1,19 @@ +import { DBSyncConfiguration } from '../DbTypes/DBSyncConfiguration'; +import { LocalFolder } from './LocalFolder'; +import { RemoteFolder } from './RemoteFolder'; + +export class SyncConfiguration { + public localFolder: LocalFolder; + public remoteFolder: RemoteFolder; + public dbSyncConfiguration: DBSyncConfiguration; + + constructor(localFolder: LocalFolder, remoteFolder: RemoteFolder, dbSyncConfiguration: DBSyncConfiguration) { + this.localFolder = localFolder; + this.remoteFolder = remoteFolder; + this.dbSyncConfiguration = dbSyncConfiguration; + } + + public get id(): number { + return this.dbSyncConfiguration.id; + } +} diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index 57fe879..b9ba0e6 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -16,6 +16,10 @@ export { BasicDataSource } from './Utils/BasicDatasource'; export { FilesDataSource } from './Utils/FilesDataSource'; +export { SyncConfiguration } from './Database/Types/SyncConfiguration'; + +export { SyncFolderManager } from './SyncEngine/SyncFolderManager'; + export { TransferManager, TransferManagerImpl, TransferTask, TransferType } from './SyncEngine/TransferManager'; export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; diff --git a/nextcloud/src/main/ets/Main.ets b/nextcloud/src/main/ets/Main.ets index f0a2b20..2104894 100644 --- a/nextcloud/src/main/ets/Main.ets +++ b/nextcloud/src/main/ets/Main.ets @@ -1,49 +1,106 @@ /** * nextcloud initialization */ -import { DEBUG } from "../../../BuildProfile"; -import { SyncFolderManager } from "./SyncEngine/SyncFolderManager"; -import { Logger } from "./Utils/Logger"; -import { DBManager } from "./Database/DBManager"; -import { NCommon } from "./Common"; -import { Account } from "./Account/Account"; +import { DEBUG } from '../../../BuildProfile'; +import { SyncFolderManager } from './SyncEngine/SyncFolderManager'; +import { Logger } from './Utils/Logger'; +import { DBManager } from './Database/DBManager'; +import { NCommon } from './Common'; +import { Account } from './Account/Account'; +import { DBAccount, DBAccountTableInfo } from './Database/DbTypes/DBAccount'; const TAG = 'Nextcloud/Init'; const DEFAULT_DATABASE_NAME = 'nextcloud_data.db'; const DEBUG_DATABASE_NAME = 'nextcloud_data-debug.db'; -class NextcloudImpl { - private dbManager: DBManager; - private syncFolderManager: SyncFolderManager; +@Observed +export class NextcloudImpl { + private dbManager_: DBManager; + private syncFolderManager_: SyncFolderManager; + private accounts_: Map = new Map(); constructor() { Logger.Info(TAG, `constructor`); - this.dbManager = new DBManager(); - this.syncFolderManager = new SyncFolderManager(this.dbManager); + this.dbManager_ = new DBManager(); + this.syncFolderManager_ = new SyncFolderManager(this.dbManager_); } - async initialize(context: Context): Promise { + public async initialize(context: Context): Promise { Logger.Info(TAG, `initialize: enter`); - await this.dbManager.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); - await this.dbManager.upgradeDatabase(); - await this.syncFolderManager.loadData(); + await this.dbManager_.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); + await this.dbManager_.upgradeDatabase(); + await this.loadAccounts(); + await this.syncFolderManager_.loadSyncFolders(); } - public hasAccounts(): boolean { - return this.syncFolderManager.hasAccounts(); + public getSyncFolderManager(): SyncFolderManager { + return this.syncFolderManager_; } - public async addAccount(account: Account): Promise { - await this.syncFolderManager.addAccount(account); + public getAccountById(accountId: number): Account { + let account = this.accounts_.get(accountId); + if (account) { + return account; + } + + Logger.Error(TAG, `getAccountById: account id ${accountId} not found, available accounts: ${Array.from(this.accounts_.keys()).join(',')}`); + if (this.accounts_.size > 0) { + account = this.accounts_.values().next().value; + Logger.Info(TAG, `getAccountById: returning first available account id ${account!.dbAccount.id}`); + return account!; + } else { + throw new Error(`No accounts available`); + } } - public getAccountById(accountId: number): Account { - return this.syncFolderManager.getAccountById(accountId); + public getAllAccounts(): Account[] { + let values = this.accounts_.values(); + return Array.from(values); + } + + public hasAccounts(): boolean { + return this.accounts_.size > 0; + } + + public async addAccount(account: Account, addToDb: boolean = true): Promise { + this.accounts_.set(account.dbAccount.id, account); + if (addToDb) { + await this.dbManager_.createItem(account.dbAccount); + } } public async recreateDatabase() { - await this.dbManager.dropDatabase(); + await this.dbManager_.dropDatabase(); + } + + private async loadAccounts(): Promise { + let dbAccounts = await this.dbManager_.getItems(DBAccountTableInfo); + for (let dbAccount of dbAccounts) { + await this.addAccount(new Account(dbAccount), false); + } + + if (dbAccounts.length === 0) { + await this.migrateAccount(); + } else { + Logger.Info(TAG, `loadAccounts: ${dbAccounts.length} accounts loaded`); + } + } + + /** + * No accounts found in database, migrate from preferences if possible + */ + private async migrateAccount() { + let prefServer = NCommon.Preference.getPref('login.server', ''); + let prefUsername = NCommon.Preference.getPref('login.username', ''); + let prefPassword = NCommon.Preference.getPref('login.appPassword', ''); + if (prefServer.length > 0 && prefUsername.length > 0 && prefPassword.length > 0) { + Logger.Info(TAG, `migrateAccount: no accounts found, migrate default account '${prefUsername}@${prefServer}' from preferences`); + let dbAccount = new DBAccount(0, prefServer, prefUsername, prefPassword); + await this.addAccount(new Account(dbAccount)); + } else { + Logger.Info(TAG, `migrateAccount: no accounts found, possibly first run`); + } } } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets deleted file mode 100644 index a846332..0000000 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolder.ets +++ /dev/null @@ -1,16 +0,0 @@ -// Describes the root sync folder that's in the system -import { Account } from "../Account/Account"; -import { DBLocalSyncFolder } from "../Database/Types/DBLocalSyncFolder"; - -const TAG = 'SyncEngine/SyncFolder'; - -export class SyncFolder { - ownerAccount: Account; - dbSyncFolder: DBLocalSyncFolder; - syncTimerId: number = 0; - - constructor(dbSyncFolder: DBLocalSyncFolder, account: Account) { - this.dbSyncFolder = dbSyncFolder; - this.ownerAccount = account; - } -} diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 3c76d0a..4a93a2d 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -1,85 +1,124 @@ // Manage mappings between Account -=> SyncFolder import { Logger } from '../Utils/Logger'; import { DBManager } from '../Database/DBManager'; +import { DBSyncConfiguration, DBSyncConfigurationTable } from '../Database/DbTypes/DBSyncConfiguration'; +import { LocalFolder } from '../Database/Types/LocalFolder'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { DBLocalSyncFolder, DBLocalSyncFolderTable } from '../Database/DbTypes/DBLocalFolder'; +import { DBRemoteFolder, DBRemoteFolderTable } from '../Database/DbTypes/DBRemoteFolder'; import { Account } from '../Account/Account'; -import { DBAccount, DBAccountTableInfo as DBAccountTable } from '../Database/Types/DBAccount'; -import { NCommon } from '../Common'; -import { SyncFolder } from './SyncFolder'; -import { DBLocalSyncFolderTableInfo as DBLocalSyncFolderTable } from '../Database/Types/DBLocalSyncFolder'; +import { SyncConfiguration } from '../Database/Types/SyncConfiguration'; const TAG = 'SyncEngine/SyncFolderManager'; +@ObservedV2 export class SyncFolderManager { - private dbManager: DBManager; - private accounts: Map = new Map(); - private folders: Map = new Map(); + @Trace private configurations_: Map = new Map(); + @Trace private localFolders_: Map = new Map(); + @Trace private remoteFolders_: Map = new Map(); + private dbManager_: DBManager; constructor(dbManager: DBManager) { Logger.Info(TAG, `constructor`); - this.dbManager = dbManager; + this.dbManager_ = dbManager; } - public async loadData(): Promise { - await this.loadAccounts(); - await this.loadSyncFolders(); + public get allSyncConfigurations(): Map { + return this.configurations_; } - public hasAccounts(): boolean { - return this.accounts.size > 0; + public async startWatchForChanges(): Promise { + Logger.Info(TAG, `startWatchForChanges: enter`); } - public getAccountById(accountId: number): Account { - let account = this.accounts.get(accountId); - if (!account) { - throw new Error(`getAccountById: account id ${accountId} not found`); - } - return account; + public async addLocalFolder(localPath: string, alias: string): Promise { + Logger.Info(TAG, `addLocalFolder: enter`); + let dbLocalFolder = new DBLocalSyncFolder(0, localPath, alias); + let localFolder = new LocalFolder(dbLocalFolder); + let insertedId = await this.dbManager_.createItem(dbLocalFolder); + dbLocalFolder.id = insertedId!; + this.localFolders_.set(dbLocalFolder.id, localFolder); + Logger.Info(TAG, `addLocalFolder: added local folder id ${dbLocalFolder.id} with path ${localPath}`); + return localFolder; + } + + public async addRemoteFolder(account: Account, remotePath: string, alias: string): Promise { + Logger.Info(TAG, `addRemoteFolder: enter`); + let dbRemoteFolder = new DBRemoteFolder(0, account.id, remotePath, alias); + let remoteFolder = new RemoteFolder(dbRemoteFolder); + let insertedId = await this.dbManager_.createItem(dbRemoteFolder); + dbRemoteFolder.id = insertedId!; + this.remoteFolders_.set(dbRemoteFolder.id, remoteFolder); + Logger.Info(TAG, `addRemoteFolder: added remote folder id ${dbRemoteFolder.id} with path ${remotePath}`); + return remoteFolder; } - public getAllAccounts(): Account[] { - let values = this.accounts.values(); - return Array.from(values); + public async addSyncConfiguration(localFolder: LocalFolder, remoteFolder: RemoteFolder): Promise { + Logger.Info(TAG, `addSyncConfiguration: enter`); + let dbSyncConfiguration = new DBSyncConfiguration(0, localFolder.id, remoteFolder.id); + let insertedId = await this.dbManager_.createItem(dbSyncConfiguration); + dbSyncConfiguration.id = insertedId!; + const syncConfig = new SyncConfiguration(localFolder, remoteFolder, dbSyncConfiguration); + this.configurations_.set(dbSyncConfiguration.id, syncConfig); + Logger.Info(TAG, `${JSON.stringify(this.configurations_.get(dbSyncConfiguration.id))}`); + Logger.Info(TAG, `addSyncConfiguration: added configuration id ${insertedId} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}`); + return syncConfig; } - public async addAccount(account: Account): Promise { - await this.dbManager.storeItem(account.dbAccount); + public async removeSyncConfiguration(syncConfiguration: SyncConfiguration): Promise { + Logger.Info(TAG, `removeSyncConfiguration: enter for configuration id ${syncConfiguration.id}`); + await this.dbManager_.deleteItemById(syncConfiguration.id, syncConfiguration.dbSyncConfiguration); + this.configurations_.delete(syncConfiguration.id); + Logger.Info(TAG, `removeSyncConfiguration: removed configuration id ${syncConfiguration.id}`); } - private async loadSyncFolders() { - let dbFolders = await this.dbManager.getItems(DBLocalSyncFolderTable); - for (let dbFolder of dbFolders) { - let ownerAccount = this.accounts.get(dbFolder.accountId); - if (!ownerAccount) { - Logger.Error(TAG, `loadSyncFolders: no owner account found for folder id ${dbFolder.id}, account id ${dbFolder.accountId}`); + public async loadSyncFolders(): Promise { + Logger.Info(TAG, `loadSyncFolders: enter`); + let dbSyncConfiguration = await this.dbManager_.getItems(DBSyncConfigurationTable); + for (let conf of dbSyncConfiguration) { + const localFolder = await this.getLocalFolderById(conf.localFolderId); + const remoteFolder = await this.getRemoteFolderById(conf.remoteFolderId); + if (!localFolder || !remoteFolder) { + Logger.Error(TAG, `loadSyncFolders: failed to load sync configuration id ${conf.id} due to missing local or remote folder`); continue; } - let syncFolder = new SyncFolder(dbFolder, ownerAccount); - this.folders.set(dbFolder.id, syncFolder); + + const syncConfig = new SyncConfiguration(localFolder, remoteFolder, conf); + this.configurations_.set(conf.id, syncConfig); + Logger.Info(TAG, `loadSyncFolders: loaded sync configuration id ${conf.id} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}`); } + Logger.Info(TAG, `loadSyncFolders: loaded total ${this.configurations_.size} sync configurations`); } - private async loadAccounts(): Promise { - let dbAccounts = await this.dbManager.getItems(DBAccountTable); - for (let dbAccount of dbAccounts) { - let account = new Account(dbAccount); - this.accounts.set(dbAccount.id, account); + private async getLocalFolderById(id: number): Promise { + let existing = this.localFolders_.get(id); + if (existing) { + return existing; } - if (dbAccounts.length === 0) { - // No accounts found in database, migrate from preferences if possible - let prefServer = NCommon.Preference.getPref('login.server', ''); - let prefUsername = NCommon.Preference.getPref('login.username', ''); - let prefPassword = NCommon.Preference.getPref('login.appPassword', ''); - if (prefPassword.length > 0) { - Logger.Info(TAG, `loadAccounts: no accounts found, migrate default account '${prefUsername}@${prefServer}' from preferences`); - let dbAccount = new DBAccount(0, prefServer, prefUsername, prefPassword); - this.accounts.set(dbAccount.id, new Account(dbAccount)); - await this.dbManager.storeItem(dbAccount); - } else { - Logger.Info(TAG, `loadAccounts: no accounts found, possibly first run`); - } - } else { - Logger.Info(TAG, `loadAccounts: ${dbAccounts.length} accounts loaded`); + let dbLocalFolder = await this.dbManager_.getItemById(id, DBLocalSyncFolderTable.columns.folder_id, DBLocalSyncFolderTable); + if (!dbLocalFolder) { + Logger.Error(TAG, `getLocalFolderById: local folder id ${id} not found`); + return null; + } + existing = new LocalFolder(dbLocalFolder); + this.localFolders_.set(id, existing); + return existing; + } + + private async getRemoteFolderById(id: number): Promise { + let existing = this.remoteFolders_.get(id); + if (existing) { + return existing; + } + + let dbRemoteFolder = await this.dbManager_.getItemById(id, DBRemoteFolderTable.columns.folder_id, DBRemoteFolderTable); + if (!dbRemoteFolder) { + Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); + return null; } + existing = new RemoteFolder(dbRemoteFolder); + this.remoteFolders_.set(id, existing); + return existing; } } From 0a00175f9ded8c3186e070e20624d6eee91d9b72 Mon Sep 17 00:00:00 2001 From: Moody Date: Sat, 24 Jan 2026 17:17:29 +0800 Subject: [PATCH 10/16] temp --- entry/build-profile.json5 | 30 ++-- .../main/ets/entryability/EntryAbility.ets | 34 ++-- entry/src/main/ets/models/Category.ets | 143 ----------------- entry/src/main/ets/pages/Index.ets | 4 +- entry/src/main/ets/pages/PcMainPage.ets | 82 +++++++--- entry/src/main/ets/pages/SettingsPage.ets | 46 ------ entry/src/main/ets/pages/UploadPage.ets | 42 ++--- .../src/main/ets/statusbar/StatusBarPage.ets | 83 ++++++---- entry/src/main/ets/utils/Common.ets | 2 + entry/src/main/ets/utils/GlobalStorage.ets | 3 - .../main/ets/utils/NotificationManager.ets | 91 +++++------ .../src/main/ets/utils/PhotoAssetsManager.ets | 95 ------------ entry/src/main/ets/utils/PhotoMonitor.ets | 23 ++- entry/src/main/ets/utils/Utils.ets | 16 -- entry/src/main/module.json5 | 11 +- hvigor/hvigor-config.json5 | 28 ++-- nextcloud/src/main/ets/Common.ets | 61 +------- nextcloud/src/main/ets/Database/DBManager.ets | 34 +++- .../ets/Database/DbTypes/DBLocalFolder.ets | 24 ++- .../main/ets/Database/Types/LocalFolder.ets | 22 ++- nextcloud/src/main/ets/Index.ets | 10 +- .../src/main/ets/SyncEngine/Category.ets | 138 +++++++++++++++++ .../ets/SyncEngine}/ImageListDataSource.ets | 2 +- .../ets/SyncEngine/PhotoAssetsManager.ets | 145 ++++++++++++++++++ .../src/main/ets/SyncEngine}/PhotoItem.ets | 20 +-- .../main/ets/SyncEngine/SyncFolderManager.ets | 63 ++++++-- .../main/ets/SyncEngine/TransferManager.ets | 72 +++++++-- .../SyncEngine/TransferManagerEventIPC.ets | 65 ++++++++ nextcloud/src/main/ets/Utils/CommonUtils.ets | 16 ++ nextcloud/src/main/ets/Utils/Logger.ts | 24 +-- nextcloud/src/main/ets/webdav/Uploader.ets | 39 ++++- oh-package.json5 | 6 +- 32 files changed, 875 insertions(+), 599 deletions(-) delete mode 100644 entry/src/main/ets/models/Category.ets delete mode 100644 entry/src/main/ets/utils/PhotoAssetsManager.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/Category.ets rename {entry/src/main/ets/models => nextcloud/src/main/ets/SyncEngine}/ImageListDataSource.ets (87%) create mode 100644 nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets rename {entry/src/main/ets/models => nextcloud/src/main/ets/SyncEngine}/PhotoItem.ets (85%) create mode 100644 nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 index 5100df5..5e10689 100644 --- a/entry/build-profile.json5 +++ b/entry/build-profile.json5 @@ -1,20 +1,22 @@ { "apiType": "stageMode", "buildOption": { - "resOptions": { - "copyCodeResource": { - "enable": false - } - }, + "resOptions": {}, "externalNativeOptions": { "path": "src/main/cpp/CMakeLists.txt", - "arguments": "", - "cppFlags": "" + "cppFlags": "", + "arguments": "" } }, "buildOptionSet": [ { "name": "release", + "nativeLib": { + "debugSymbol": { + "strip": true, + "exclude": [] + } + }, "arkOptions": { "obfuscation": { "ruleOptions": { @@ -24,18 +26,12 @@ ] } } - }, - "nativeLib": { - "debugSymbol": { - "strip": true, - "exclude": [] - } } - }, + } ], "targets": [ { - "name": "default", - }, + "name": "default" + } ] -} +} \ No newline at end of file diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index ea9e812..1055944 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,9 +1,9 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { AsyncSleep, InitialiseNextcloud, Logger } from 'nextcloud'; +import { AsyncSleep, InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; -import { Notification } from '../utils/NotificationManager'; +import { NotificationManager } from '../utils/NotificationManager'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { deviceInfo } from '@kit.BasicServicesKit'; import { statusBarManager } from '@kit.DeskTopExtensionKit'; @@ -12,6 +12,12 @@ const TAG = 'EntryAbility'; export default class EntryAbility extends UIAbility { private initCompleted: boolean = false; + private readonly isPc: boolean; + + constructor() { + super(); + this.isPc = deviceInfo.deviceType === deviceInfo.DeviceTypes.TYPE_2IN1; + } async onCreate(_want: Want, _launchParam: AbilityConstant.LaunchParam) { Logger.Info(TAG, 'Ability onCreate'); @@ -28,25 +34,25 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Ability onDestroy'); } - async onWindowStageCreate(windowStage: window.WindowStage) { + private async ensureInitCompleted() { while (!this.initCompleted) { Logger.Info(TAG, 'Waiting for initialization to complete...'); await AsyncSleep(100); } Logger.Info(TAG, 'Initialization completed, proceeding to create main window.'); + } + async onWindowStageCreate(windowStage: window.WindowStage) { + await this.ensureInitCompleted(); let context = this.context; GlobalStorage.SandboxBasePath = context.filesDir; try { - let isPc = deviceInfo.deviceType === deviceInfo.DeviceTypes.TYPE_2IN1; - let page = isPc ? 'pages/PcMainPage' : 'pages/Index'; - if (!isPc) { - GlobalStorage.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); - await Notification.startNotification(this.context); - } + let page = this.isPc ? 'pages/PcMainPage' : 'pages/Index'; + await NotificationManager.startNotification(this.context); + NCommon.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); await windowStage.loadContent(page); - if (isPc) { + if (this.isPc) { this.createTrayIcon(); } else { PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); @@ -65,8 +71,12 @@ export default class EntryAbility extends UIAbility { * @throws */ async onPrepareToTerminateAsync(): Promise { - await this.context.hideAbility(); - return true; + if (this.isPc) { + await this.context.hideAbility(); + return true; + } else { + return false; + } } onWindowStageDestroy() { diff --git a/entry/src/main/ets/models/Category.ets b/entry/src/main/ets/models/Category.ets deleted file mode 100644 index 8e6210e..0000000 --- a/entry/src/main/ets/models/Category.ets +++ /dev/null @@ -1,143 +0,0 @@ -import { Logger, NCommon } from 'nextcloud'; -import { ImageListDataSource } from './ImageListDataSource'; -import { PhotoItem, UploadResult } from './PhotoItem'; -import { promptAction } from '@kit.ArkUI'; -import { Notification } from '../utils/NotificationManager'; - -const TAG = 'models/Category'; - -export enum AddAssetResult { - Added, - Unmatched, - Throttled -} - -export enum AddPosition { - Append, - Prepend -} - - -const IMAGE_LIST_MAX_ITEMS = 50; - -@Observed -export class Category implements NCommon.CategoryConfig { - name: string; - patterns: string[]; - location: string; - /** - * Items that are currently visible on the UI (not all assets are listed for performance reason) - */ - visibleItems: ImageListDataSource = new ImageListDataSource(); - invisibleBackedItems: Map = new Map(); - - constructor(other: NCommon.CategoryConfig) { - this.name = other.name; - this.patterns = other.patterns; - this.location = other.location; - } - - tryAddAsset(asset: PhotoItem, position: AddPosition): AddAssetResult { - if (!this.matchesAsset(asset)) { - return AddAssetResult.Unmatched; - } - - switch (position) { - case AddPosition.Append: { - if (this.visibleItems.totalCount() > IMAGE_LIST_MAX_ITEMS) { - this.invisibleBackedItems.set(asset.uri, asset); - return AddAssetResult.Throttled; - } else { - this.visibleItems.addAsset(asset); - return AddAssetResult.Added; - } - } - case AddPosition.Prepend: { - this.visibleItems.prependAsset(asset); - return AddAssetResult.Added; - } - } - } - - showNext50(): void { - Logger.Info(TAG, `loadNext50 enter`); - let count = 0; - while (this.invisibleBackedItems.size > 0 && count < 50) { - let asset = this.invisibleBackedItems.values().next().value as PhotoItem; - this.invisibleBackedItems.delete(asset.uri); - this.visibleItems.addAsset(asset); - count++; - } - } - - getAssets(start: number, length: number): PhotoItem[] { - let result: PhotoItem[] = []; - // Get from items if it's completely within range - if (start + length <= this.visibleItems.totalCount()) { - result = this.visibleItems.getDataRange(start, length); - } else if (start >= this.visibleItems.totalCount()) { - // Get from backedItems if it's completely within backedItems - let backedStart = start - this.visibleItems.totalCount(); - for (let i = backedStart; i < Math.min(backedStart + length, this.invisibleBackedItems.size); i++) { - result.push(this.invisibleBackedItems[i]); - } - } else { - // Get from both items and backedItems - let itemsPartLength = this.visibleItems.totalCount() - start; - result = this.visibleItems.getDataRange(start, itemsPartLength); - let backedPartLength = length - itemsPartLength; - for (let i = 0; i < Math.min(backedPartLength, this.invisibleBackedItems.size); i++) { - result.push(this.invisibleBackedItems[i]); - } - } - return result; - } - - getTotalAvailableCount(): number { - return this.visibleItems.totalCount() + this.invisibleBackedItems.size; - } - - async uploadAllAssets(notify: boolean = true) { - const BATCH_SIZE = 8; - const totalCount = this.getTotalAvailableCount(); - let total = Math.ceil(totalCount / BATCH_SIZE) * BATCH_SIZE; - let totalUploaded = 0; - for (let i = 0; i < total; i += BATCH_SIZE) { - let batch = this.getAssets(i, BATCH_SIZE); - - let canStop = true; - - let futures = batch.map(asset => { - return asset.upload(NCommon.Preference.baseUploadDirectory + this.location).then((result) => { - canStop = canStop && result === UploadResult.EXISTED; - totalUploaded++; - }).then(() => { - if (notify) { - Notification.publishUploadProgress(totalUploaded, totalCount) - } - }); - }); - - await Promise.all(futures); - if (canStop) { - if (notify) { - Notification.publishIdleNotification(); - } - promptAction.showToast({ - message: `Upload is probably completed`, - duration: 2000, - }); - break; - } - } - } - - private matchesAsset(asset: PhotoItem): boolean { - for (let p of this.patterns) { - if (RegExp(p).test(asset.displayName)) { - return true; - } - } - return false; - } -} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 4c6fa5c..04a8163 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -7,7 +7,7 @@ import { SettingsPage } from './SettingsPage'; import { FolderPage } from './FolderPage'; import { Constants, FolderViewMode } from '../utils/Common'; import { PermissionManager } from '../utils/PermissionManager'; -import { PhotoAssetsManager } from '../utils/PhotoAssetsManager'; +import { PhotoAssetsManager } from 'nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager'; import { PhotoMonitor } from '../utils/PhotoMonitor'; import { ShowToast } from '../utils/Utils'; import { Permissions } from '@kit.AbilityKit'; @@ -59,7 +59,7 @@ struct Index { ]; let result = await PermissionManager.RequestPermission(context, PERMISSIONS); Logger.Info(TAG, `Request permission result: ${result}`); - PhotoAssetsManager.initialize(NCommon.Preference.categories); + PhotoAssetsManager.initialize(Nextcloud.getSyncFolderManager().allLocalFolders); PhotoMonitor.beginMonitorPhotoChange(); } diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 0053615..0558917 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -18,6 +18,10 @@ import { PermissionManager } from '../utils/PermissionManager'; import { LengthMetrics } from '@kit.ArkUI'; import { DownloadQueue } from '../utils/DownloadQueue'; import { SettingsPage } from './SettingsPage'; +import { PhotoAssetsManager } from 'nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager'; +import { UploadPage } from './UploadPage'; +import { emitter } from '@kit.BasicServicesKit'; +import { Constants } from '../utils/Common'; const TAG = 'PcAbility' const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; @@ -53,7 +57,9 @@ struct SyncConfigurationRow { build() { Row({ space: 8 }) { - Text(`${this.configuration.id} : ${this.configuration.localFolder.localPath} <=> ${this.configuration.remoteFolder.remotePath}`) + Text( + `${this.configuration.id} : ${this.configuration.localFolder.localPath} <=> ${this.configuration.remoteFolder.remotePath}` + ) Blank() .flexGrow(1) @@ -99,7 +105,13 @@ struct SyncConfigurationRow { } catch (e) { } } else { - await DownloadFile(this.account, next.relativePath, this.configuration.localFolder.localPath + '/' + next.relativePath); + await DownloadFile( + this.account, + next.relativePath, + this.configuration.localFolder.localPath + + '/' + + next.relativePath + ); } Logger.Info(TAG, next.relativePath); } @@ -130,16 +142,20 @@ struct PCAbility { .width('100%') } - ForEach(Array.from(this.syncFolderManager.allSyncConfigurations), (conf: [number, SyncConfiguration], index: number) => { - ListItem() { - SyncConfigurationRow({ - account: this.account, - syncFolderManager: this.syncFolderManager, - configuration: conf[1], - }) - } - .clip(true) - }) + ForEach( + Array.from(this.syncFolderManager.allSyncConfigurations), + ( + conf: [number, SyncConfiguration], + _index: number) => { + ListItem() { + SyncConfigurationRow({ + account: this.account, + syncFolderManager: this.syncFolderManager, + configuration: conf[1], + }) + } + .clip(true) + }) } .width('100%') .height('100%') @@ -184,6 +200,8 @@ struct PCAbility { transferTask: item }) } + }, (item: TransferTask, _index: number) => { + return `${item.id}` }) } .width('100%') @@ -216,18 +234,24 @@ struct PCAbility { } async aboutToAppear(): Promise { - Logger.Info(TAG, `pcAbility aboutToAppear`); - await PermissionManager.RequestPermission(this.getUIContext(), ["ohos.permission.FILE_ACCESS_PERSIST"]) - for (let syncConfiguration of this.syncFolderManager.allSyncConfigurations.values()) { - Logger.Info(TAG, `configuration: ${syncConfiguration.id} --- ${syncConfiguration.localFolder.localPath} <=> ${syncConfiguration.remoteFolder.remotePath}`); - CloudSyncApi.connect(syncConfiguration.localFolder.localPath); + Logger.Info(TAG, `aboutToAppear`); + await PermissionManager.RequestPermission(this.getUIContext(), [ + 'ohos.permission.FILE_ACCESS_PERSIST', + 'ohos.permission.READ_IMAGEVIDEO', + 'ohos.permission.MEDIA_LOCATION', + ]); + + PhotoAssetsManager.initialize(this.syncFolderManager.allLocalFolders); + for (let conf of this.syncFolderManager.allSyncConfigurations.values()) { + Logger.Info(TAG, `conf: ${conf.id} - ${conf.localFolder.localPath} <=> ${conf.remoteFolder.remotePath}`); + CloudSyncApi.connect(conf.localFolder.localPath); try { await fileShare.activatePermission([{ - uri: fileUri.getUriFromPath(syncConfiguration.localFolder.localPath), + uri: fileUri.getUriFromPath(conf.localFolder.localPath), operationMode: OPERATION_MODES }]); - Logger.Info(TAG, `activate permission done for '${syncConfiguration.localFolder.localPath}'`); + Logger.Info(TAG, `activate permission done for '${conf.localFolder.localPath}'`); } catch (e) { Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); } @@ -237,6 +261,11 @@ struct PCAbility { this.onFileChanged(info) }); + emitter.on(Constants.EMITTER_EVENT_NOTIFICATION_ON_IDLE, async () => { + for (let i = 0; i < 10; i++) { + await ListFiles(this.account, '/'); + } + }); this.files = await ListFiles(this.account, '/'); } @@ -253,6 +282,13 @@ struct PCAbility { } .tabBar('Paths') + TabContent() { + UploadPage() + .width('100%') + .height('100%') + } + .tabBar('Upload') + TabContent() { this.pcMainPage() } @@ -296,7 +332,13 @@ struct PCAbility { let localPathAlias = 'LocalPathAlias_' + Date.now(); let localFolder = await this.syncFolderManager.addLocalFolder(path, localPathAlias); Logger.Info(TAG, `added local folder: ${JSON.stringify(localFolder)}`); - let remoteFolder = await this.syncFolderManager.addRemoteFolder(this.account, '/', 'RemotePathAlias_' + Date.now()); + let remoteFolder = + await this.syncFolderManager.addRemoteFolder( + this.account, + '/', + 'RemotePathAlias_' + + Date.now() + ); Logger.Info(TAG, `added remote folder: ${JSON.stringify(remoteFolder)}`); let syncConfig = await this.syncFolderManager.addSyncConfiguration(localFolder, remoteFolder); Logger.Info(TAG, `added sync configuration: ${JSON.stringify(syncConfig)}`); diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 1294f40..2c48107 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -5,19 +5,6 @@ import { ShowToast } from '../utils/Utils'; const TAG = 'pages/Settings'; -@Observed -class EditableCategory implements NCommon.CategoryConfig { - name: string; - patterns: string[]; - location: string; - - constructor(other: NCommon.CategoryConfig) { - this.name = other.name; - this.patterns = other.patterns; - this.location = other.location; - } -} - @Component export struct SettingsPage { scroller: Scroller = new Scroller(); @@ -26,7 +13,6 @@ export struct SettingsPage { @State private hostname: string = ""; @State private uploadReplaceExisting: boolean = false; @State private uploadBaseDirectory: string = '/'; - @State private uploadCategories: EditableCategory[] = []; aboutToAppear(): void { this.doLoadSettings(); @@ -112,33 +98,6 @@ export struct SettingsPage { .width('100%') .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) - - ForEach(this.uploadCategories, (category: EditableCategory, _index: number) => { - Column({ space: 4 }) { - Row() { - Text(category.name) - .fontWeight(FontWeight.Bold) - - Text(this.uploadBaseDirectory + category.location) - .fontSize(12) - .fontColor(Color.Gray) - } - .justifyContent(FlexAlign.SpaceBetween) - .width('100%') - - ForEach(category.patterns, (pattern: string, patternIndex: number) => { - TextInput({ text: pattern }) - .fontSize(12) - .fontFamily('monospace') - .width('100%') - .onChange((content) => { - category.patterns[patternIndex] = content; - }) - }) - } - .width('100%') - .padding(8) - }) } } .margin({ left: 24, right: 24 }) @@ -174,10 +133,6 @@ export struct SettingsPage { this.hostname = this.currentAccount.hostname; this.uploadReplaceExisting = NCommon.Preference.replaceExistingFiles; this.uploadBaseDirectory = NCommon.Preference.baseUploadDirectory; - this.uploadCategories = []; - for (let c of NCommon.Preference.categories) { - this.uploadCategories.push(new EditableCategory(c)); - } } else { Logger.Error(TAG, `failed to load settings, preference is undefined`); } @@ -187,7 +142,6 @@ export struct SettingsPage { if (NCommon.Preference) { NCommon.Preference.replaceExistingFiles = this.uploadReplaceExisting; NCommon.Preference.baseUploadDirectory = this.uploadBaseDirectory; - NCommon.Preference.categories = this.uploadCategories; } else { Logger.Error(TAG, `failed to save settings, preference is undefined`); } diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 497727c..7ccd01b 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,8 +1,8 @@ import { Logger, NCommon } from 'nextcloud'; -import { Category } from '../models/Category'; +import { Category } from 'nextcloud/src/main/ets/SyncEngine/Category'; import { LengthMetrics } from '@kit.ArkUI'; -import { PhotoItem, UploadStatus } from '../models/PhotoItem'; -import { PhotoAssetsManager } from '../utils/PhotoAssetsManager'; +import { PhotoItem, UploadStatus } from 'nextcloud/src/main/ets/SyncEngine/PhotoItem'; +import { PhotoAssetsManager } from 'nextcloud'; const TAG = "Page-UploadPage" @@ -48,7 +48,7 @@ struct PhotoView { .borderWidth(3) .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { - await this.asset.upload(NCommon.Preference.baseUploadDirectory + this.category.location); + // await this.asset.upload(NCommon.Preference.baseUploadDirectory + this.category.location); }) .onAppear(async () => { if (this.thumbnail === null) { @@ -73,7 +73,7 @@ struct PhotosGridView { .width('100%') .onAppear(() => { if (index == this.category.visibleItems.totalCount() - 1) { - this.category.showNext50(); + this.category.loadNextBatch(); } }) }) @@ -92,7 +92,7 @@ struct PhotosGridView { Button($r('app.string.upload_all')) .onClick(() => { Logger.Info(TAG, `Upload all in '${this.category.name}'`); - this.category.uploadAllAssets(); + // this.category.uploadAllAssets(); }) .width('30%') } @@ -104,39 +104,39 @@ struct PhotosGridView { @Preview -@Component +@ComponentV2 export struct UploadPage { - @State private categories: Category[] = PhotoAssetsManager.categories; + @Local private categories: Map = PhotoAssetsManager.categories; build() { Tabs() { - ForEach(this.categories, (category: Category) => { + ForEach(Array.from(this.categories), (category: [number, Category]) => { TabContent() { Flex({ direction: FlexDirection.Column, space: { main: LengthMetrics.px(20) } }) { - Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Baseline }) { - Text(category.name) + Flex({ + direction: FlexDirection.Row, + justifyContent: FlexAlign.SpaceBetween, + alignItems: ItemAlign.Baseline, + }) { + Text(category[1].name) .fontWeight(FontWeight.Bold) .fontSize(24) - Text(category.location) + Text('category.location') } .height(32) - ForEach(category.patterns, (pattern: string) => { - Text(pattern) - .fontSize(12) - .fontColor(Color.Green) - .fontFamily('serif') - }) - - PhotosGridView({ category: category }) + PhotosGridView({ category: category[1] }) } .width('100%') .height('100%') } .margin(10) - .tabBar(category.name) + .tabBar(category[1].name) }) } + .vertical(true) + .barWidth(100) + .barMode(BarMode.Scrollable) } } diff --git a/entry/src/main/ets/statusbar/StatusBarPage.ets b/entry/src/main/ets/statusbar/StatusBarPage.ets index fa794ad..1677502 100644 --- a/entry/src/main/ets/statusbar/StatusBarPage.ets +++ b/entry/src/main/ets/statusbar/StatusBarPage.ets @@ -1,9 +1,20 @@ -import { TransferTask, TransferType } from 'nextcloud'; -import { LengthMetrics } from '@kit.ArkUI'; +import { + TransferTask, + Logger, +} from 'nextcloud'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { commonEventManager } from '@kit.BasicServicesKit'; +import { + TRANSFER_MANAGER_COMMON_EVENT_NAME, + TransferManagerEvent, + TransferManagerEventCode, +} from 'nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC'; -@Component +const TAG = 'StatusBar/Page'; + +@ComponentV2 struct TransferProgressItem { - @ObjectLink transferTask: TransferTask; + @Require @Param transferTask: TransferTask; build() { Column({ space: 4 }) { @@ -19,7 +30,6 @@ struct TransferProgressItem { .flexGrow(1) Text(this.transferTask.progressPercentText) - // .flexBasis() } .backgroundColor($r('sys.color.ohos_id_color_hover')) .padding(8) @@ -29,40 +39,61 @@ struct TransferProgressItem { } @Entry -@Component +@ComponentV2 struct StatusBarPage { - @State tasks: TransferTask[] = []; + @Local tasks: Map = new Map(); + private subscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; + private readonly subscribeInfo: commonEventManager.CommonEventSubscribeInfo = { + events: [TRANSFER_MANAGER_COMMON_EVENT_NAME] + }; - aboutToAppear(): void { - for (let i = 0; i < 80; i++) { - this.tasks.push(new TransferTask(TransferType.DOWNLOAD, `file name ${i}`)); + private readonly subscribeCB = async (data: commonEventManager.CommonEventData) => { + console.info(`Succeeded in subscribing, data is ` + JSON.stringify(data)); + let event: TransferManagerEvent = JSON.parse(data.data!); + switch (event.code) { + case TransferManagerEventCode.TASK_CREATE: { + this.tasks.set(event.taskId, new TransferTask(event.task)); + break; + } + case TransferManagerEventCode.TASK_PROGRESS: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = event.transferredSize; + break; + } + case TransferManagerEventCode.TASK_COMPLETED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = task.totalSize; + break; + } + case TransferManagerEventCode.TASK_FAILED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = 0; + break; + } } } - onPageShow(): void { - for (let task of this.tasks) { - let total = Math.random() * 10000; - let transferred = Math.random() * total; - task.updateProgress(transferred, total); - } - - if (false) { - setInterval(() => { - for (let task of this.tasks) { - let transferred = Math.random() * task.totalSize; - task.updateProgress(transferred, task.totalSize); - } - }, 1000); + async aboutToAppear(): Promise { + try { + this.subscriber = await commonEventManager.createSubscriber(this.subscribeInfo); + Logger.Info(TAG, `succeeded in creating subscriber.`); + await commonEventManager.subscribeToEvent(this.subscriber, this.subscribeCB); + Logger.Info(TAG, `succeeded in subscribing`); + } catch (error) { + let err: BusinessError = error as BusinessError; + Logger.Error(TAG, `Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); } } build() { Scroll() { Column({ space: 16 }) { - ForEach(this.tasks, (item: TransferTask) => { + ForEach(Array.from(this.tasks).reverse(), (item: [number, TransferTask]) => { TransferProgressItem({ - transferTask: item + transferTask: item[1] }) + }, (item: [number, TransferTask], index: number) => { + return `${item[0]}` }) } } diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index 9244459..bab9dfb 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -8,4 +8,6 @@ export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; export const PHONE_ACTIVE_ACCOUNT_ID = 'Phone.ActiveAccountId'; + + export const EMITTER_EVENT_NOTIFICATION_ON_IDLE: string = 'Events.Notification.OnIdle'; } diff --git a/entry/src/main/ets/utils/GlobalStorage.ets b/entry/src/main/ets/utils/GlobalStorage.ets index 6448c70..7e1567b 100644 --- a/entry/src/main/ets/utils/GlobalStorage.ets +++ b/entry/src/main/ets/utils/GlobalStorage.ets @@ -1,8 +1,5 @@ -import { photoAccessHelper } from '@kit.MediaLibraryKit'; - export const INSTANT_UPLOAD_PATH = '/InstantUpload/'; export class GlobalStorage { public static SandboxBasePath: string = ""; - public static pHelper: photoAccessHelper.PhotoAccessHelper; } diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets index b09ab47..b92cbdc 100644 --- a/entry/src/main/ets/utils/NotificationManager.ets +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -4,7 +4,6 @@ import { Logger } from 'nextcloud'; import { BUNDLE_NAME } from 'BuildProfile'; import { BusinessError } from '@ohos.base'; import { notificationManager } from '@kit.NotificationKit'; -import { PhotoAssetsManager } from './PhotoAssetsManager'; const TAG = "Util-NotificationManager"; @@ -15,63 +14,65 @@ class NotificationImpl { requestCode: 0, wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] }; - private readonly BACKGROUND_JOB_TYPE: Array = ["dataTransfer"]; + private notificationId: Optional = undefined; - private refreshNotificationTimer: number = 0; + private idleTimer: number = 0; public async startNotification(context: Context) { + Logger.Info(TAG, `startNotification`); try { let wantAgentObj = await wantAgent.getWantAgent(this.WANT_AGENT_INFO); - let res = await backgroundTaskManager.startBackgroundRunning(context, this.BACKGROUND_JOB_TYPE, wantAgentObj); - this.notificationId = res.notificationId; - await this.publishIdleNotification(); - Logger.Info(TAG, `Operation startBackgroundRunning succeeded: ${this.notificationId}`); + let req = new backgroundTaskManager.ContinuousTaskRequest(); + req.backgroundTaskModes = [backgroundTaskManager.BackgroundTaskMode.MODE_TASK_KEEPING]; + req.backgroundTaskSubmodes = [backgroundTaskManager.BackgroundTaskSubmode.SUBMODE_NORMAL_NOTIFICATION]; + req.wantAgent = wantAgentObj; + + let res = await backgroundTaskManager.startBackgroundRunning(context, req); + Logger.Info(TAG, `${JSON.stringify(res)}`); + + + this.notificationId = 111; + await this.startIdleLoop(); + Logger.Info(TAG, `startNotification: succeeded: ${this.notificationId}`); } catch (error) { let e = error as BusinessError; - Logger.Error(TAG, `Operation startBackgroundRunning failed. code is ${e.code} message is ${e.message}`); + Logger.Error(TAG, `startNotification: failed. code is ${e.code} message is ${e.message}`); } } - public async publishUploadProgress(currentNumber: number, totalNumber: number) { + public async beginIdle() { + Logger.Info(TAG, `startIdle: enter`); if (this.notificationId === undefined) { - Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); + Logger.Warn(TAG, `startIdle: notificationId is undefined, return directly`); return; } - let process = Math.floor((currentNumber / totalNumber) * 100); - await this.publishUploadProgressInner('Nextcloud Upload', `Uploading ${currentNumber} of ${totalNumber}`, process); + await this.startIdleLoop(); } - public async publishIdleNotification() { + public async publishNotification(title: string, content: string, percent: number) { if (this.notificationId === undefined) { - Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); + Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); return; } - let randomProgress = Math.floor(Math.random() * 100); - await this.publishUploadProgressInner('Nextcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, randomProgress); - - clearInterval(this.refreshNotificationTimer); - this.refreshNotificationTimer = setInterval(async () => { - await this.publishIdleNotification(); - await PhotoAssetsManager.doFullUpload(false); // false = do not notify again - }, 3 * 30 * 1000); + clearInterval(this.idleTimer); + await this.doPublishNotification(title, content, percent); } - public async publishPendingNotification(due: Date, percent: number) { - if (this.notificationId === undefined) { - Logger.Warn(TAG, "publishIdleUploadNotification: notificationId is undefined, return directly"); - return; - } - await this.publishUploadProgressInner('Nextcloud Pending Upload', `Scheduled at ${due.toLocaleTimeString()}`, percent); - } + private async startIdleLoop(): Promise { + let doPublishIdleNotificaton = async () => { + await this.doPublishNotification('Nexcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, 1); + }; - private async publishUploadProgressInner(title: string, subtitle: string, progress: number) { - clearInterval(this.refreshNotificationTimer); - await this.doPublishUploadProgress(title, subtitle, progress); + await doPublishIdleNotificaton(); + clearInterval(this.idleTimer); + this.idleTimer = setInterval(async () => { + await doPublishIdleNotificaton(); + }, 90 * 1000); ///< 90s } - private async doPublishUploadProgress(title: string, subtitle: string, progress: number) { - Logger.Info(TAG, `publishUploadProgress: title='${title}', subtitle='${subtitle}', progress=${progress} to ${this.notificationId}`); + private async doPublishNotification(title: string, subtitle: string, progress: number) { + Logger.Info(TAG, `doPublishNotification: '${title}', '${subtitle}', ${progress}% -> ${this.notificationId}`); let downloadTemplate: notificationManager.NotificationTemplate = { name: 'downloadTemplate', data: { @@ -81,22 +82,24 @@ class NotificationImpl { } }; - let request: notificationManager.NotificationRequest = { + let notificationRequest: notificationManager.NotificationRequest = { + id: this.notificationId, content: { - notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_SYSTEM_LIVE_VIEW, - systemLiveView: { typeCode: 8, title: '', text: '', } + notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, + normal: { + title: "", + text: "", + additionalText: "", + } }, - id: this.notificationId, - notificationSlotType: notificationManager.SlotType.LIVE_VIEW, - template: downloadTemplate, + template: downloadTemplate }; - try { - await notificationManager.publish(request); + await notificationManager.publish(notificationRequest); } catch (err) { - Logger.Error(TAG, `publish fail: ${JSON.stringify(err)}`); + Logger.Error(TAG, `doPublishNotification: publish fail: ${JSON.stringify(err)}`); } } } -export const Notification = new NotificationImpl(); +export const NotificationManager = new NotificationImpl(); diff --git a/entry/src/main/ets/utils/PhotoAssetsManager.ets b/entry/src/main/ets/utils/PhotoAssetsManager.ets deleted file mode 100644 index 52f0f5c..0000000 --- a/entry/src/main/ets/utils/PhotoAssetsManager.ets +++ /dev/null @@ -1,95 +0,0 @@ -import { AsyncSleep, Logger, NCommon } from 'nextcloud'; -import { AddAssetResult, AddPosition, Category } from '../models/Category'; -import { PhotoItem } from '../models/PhotoItem'; -import { GlobalStorage } from './GlobalStorage'; -import { photoAccessHelper } from '@kit.MediaLibraryKit'; -import { dataSharePredicates } from '@kit.ArkData'; -import { Notification } from './NotificationManager'; - -const TAG = 'Utils/PhotoAssetsManager'; - -class PhotoAssetsManagerImpl { - private static readonly FETCH_COLUMNS = [ - photoAccessHelper.PhotoKeys.URI, - photoAccessHelper.PhotoKeys.PHOTO_TYPE, - photoAccessHelper.PhotoKeys.PHOTO_SUBTYPE, - photoAccessHelper.PhotoKeys.DISPLAY_NAME, - photoAccessHelper.PhotoKeys.DATE_TAKEN, - photoAccessHelper.PhotoKeys.DATE_ADDED_MS, - photoAccessHelper.PhotoKeys.SIZE, - photoAccessHelper.PhotoKeys.WIDTH, - photoAccessHelper.PhotoKeys.HEIGHT, - photoAccessHelper.PhotoKeys.TITLE, - photoAccessHelper.PhotoKeys.COVER_POSITION, - photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, - photoAccessHelper.PhotoKeys.DETAIL_TIME, - photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, - ]; - public categories: Category[] = []; - - public async initialize(categories: NCommon.CategoryConfig[]) { - this.categories = []; - for (let c of categories) { - this.categories.push(new Category(c)); - } - - const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { - fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, - predicates: new dataSharePredicates.DataSharePredicates().orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN), - }; - - try { - let result = await GlobalStorage.pHelper.getAssets(FETCH_OPTIONS); - for (let asset of await result.getAllObjects()) { - this.doAddPhotoItem(new PhotoItem(asset), AddPosition.Append); - } - result.close(); - } catch (error) { - Logger.Error(TAG, `failed to get photo assets: ${error}`); - } - } - - public async prependNewAsset(assetUri: string) { - Logger.Info(TAG, `prependNewAsset enter, uri: ${assetUri}`); - const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { - fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, - predicates: new dataSharePredicates.DataSharePredicates().equalTo(photoAccessHelper.PhotoKeys.URI, assetUri), - }; - - try { - let assetResult = await GlobalStorage.pHelper.getAssets(FETCH_OPTIONS); - if (assetResult.getCount() !== 1) { - Logger.Error(TAG, `found more than one asset with uri: ${assetUri}`); - return; - } - let photoItem = new PhotoItem(await assetResult.getFirstObject()); - Logger.Info(TAG, `prepending new asset: ${photoItem.displayName}, uri: ${photoItem.uri}`); - this.doAddPhotoItem(photoItem, AddPosition.Prepend); - assetResult.close(); - } catch (error) { - Logger.Error(TAG, `failed to add new asset: ${error}`); - } - } - - public async doFullUpload(notify: boolean = true) { - Logger.Info(TAG, `doFullUpload enter, notify: ${notify}`); - for (let category of this.categories) { - await category.uploadAllAssets(notify); - } - - await AsyncSleep(5000); // wait for 5s to ensure all uploads are started - if (notify) { - await Notification.publishIdleNotification(); - } - } - - private doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { - for (let category of this.categories) { - if (category.tryAddAsset(photoItem, addPosition) != AddAssetResult.Unmatched) { - break; - } - } - } -} - -export const PhotoAssetsManager = new PhotoAssetsManagerImpl(); diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets index 5b5d653..b6a1e08 100644 --- a/entry/src/main/ets/utils/PhotoMonitor.ets +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -1,8 +1,8 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit'; -import { Logger } from 'nextcloud'; +import { Logger, NCommon } from 'nextcloud'; import { GlobalStorage } from './GlobalStorage'; -import { Notification } from './NotificationManager'; -import { PhotoAssetsManager } from './PhotoAssetsManager'; +import { NotificationManager } from './NotificationManager'; +import { PhotoAssetsManager } from 'nextcloud'; const TAG = 'Utils-PhotoMonitor'; @@ -17,7 +17,11 @@ class PhotoMonitorImpl { public beginMonitorPhotoChange() { try { - GlobalStorage.pHelper.registerChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, true, this.onPhotoChangedCallback); + NCommon.pHelper.registerChange( + photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, + true, + this.onPhotoChangedCallback + ); } catch (error) { Logger.Error(TAG, `Failed to register photo change monitor: ${error}`); } @@ -27,7 +31,10 @@ class PhotoMonitorImpl { public stopMonitorPhotoChange() { try { - GlobalStorage.pHelper.unRegisterChange(photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, this.onPhotoChangedCallback); + NCommon.pHelper.unRegisterChange( + photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, + this.onPhotoChangedCallback + ); } catch (error) { Logger.Error(TAG, `Failed to unregister photo change monitor: ${error}`); } @@ -39,7 +46,7 @@ class PhotoMonitorImpl { this.fullUploadTimer = setInterval(async () => { Logger.Info(TAG, `>>> Scheduled full upload beginning`); - await PhotoAssetsManager.doFullUpload(); + // await PhotoAssetsManager.doFullUpload(); let nextTime = new Date(Date.now() + PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); Logger.Info(TAG, `<<< Scheduled full upload completed, next time: ${nextTime}`); }, PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); @@ -63,13 +70,13 @@ class PhotoMonitorImpl { this.doUploadTimer = setTimeout(async () => { Logger.Info(TAG, `>>> Scheduled photo upload beginning`); - await PhotoAssetsManager.doFullUpload(); + // await PhotoAssetsManager.doFullUpload(); Logger.Info(TAG, `<<< Scheduled photo upload completed`); this.scheduleFullUpload(); }, PhotoMonitorImpl.UPLOAD_DELAY_MS); let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.UPLOAD_DELAY_MS); - Notification.publishPendingNotification(nextUploadTime, 66); + NotificationManager.publishNotification('nextUploadTime', '', 1); Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); } } diff --git a/entry/src/main/ets/utils/Utils.ets b/entry/src/main/ets/utils/Utils.ets index c702d0a..d5552d3 100644 --- a/entry/src/main/ets/utils/Utils.ets +++ b/entry/src/main/ets/utils/Utils.ets @@ -15,22 +15,6 @@ export function FormatBytes(bytes: string) { return bytesNum.toFixed(2) + ' ' + units[i]; } -export function FormatTime(date: Date, format: string) { - // make sure the path format ends with a slash - if (!format.endsWith('/')) { - format += '/'; - } - - let result = format; - result = result.replace('yyyy', date.getFullYear().toString()); - result = result.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0')); - result = result.replace('dd', date.getDate().toString().padStart(2, '0')); - result = result.replace('HH', date.getHours().toString().padStart(2, '0')); - result = result.replace('mm', date.getMinutes().toString().padStart(2, '0')); - result = result.replace('ss', date.getSeconds().toString().padStart(2, '0')); - return result; -} - export function ShowToast(context: UIContext, message: string | Resource, duration: number = 1500): void { try { Logger.Info(TAG, `Showing toast: '${message}' for ${duration}ms`); diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index ff52132..55d1a85 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -8,6 +8,15 @@ { "name": "ohos.permission.INTERNET" }, + { + "name": "ohos.permission.KEEP_BACKGROUND_RUNNING_SYSTEM", + "reason": "$string:permission_media_location_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + }, { "name": "ohos.permission.READ_IMAGEVIDEO", "reason": "$string:permission_image_video_reason", @@ -66,7 +75,7 @@ } ], "backgroundModes": [ - "dataTransfer" + "taskKeeping" ] } ], diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 index dbbc3d0..3fb55ee 100644 --- a/hvigor/hvigor-config.json5 +++ b/hvigor/hvigor-config.json5 @@ -1,24 +1,26 @@ { - "modelVersion": "5.0.1", + "modelVersion": "6.0.0", "dependencies": { }, "execution": { - // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ - // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ - // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ - // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ - // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + "analyze": "advanced", + /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + "typeCheck": true, + /* Enable typeCheck. Value: [ true | false ]. Default: false */ }, "logging": { - "level": "info" - /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + "level": "info", + /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ }, "debugging": { - "stacktrace": true - /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + "stacktrace": true, + /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ }, "nodeOptions": { - // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ - // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ } -} +} \ No newline at end of file diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index 7597521..5ddb942 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -1,61 +1,25 @@ import { deviceInfo } from '@kit.BasicServicesKit'; import { preferences } from '@kit.ArkData'; import { Logger } from './Utils/Logger'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; const TAG = "Nextcloud-Common"; const USER_AGENT_PART = "Nextcloud Client for HarmonyOS on "; -export const DEFAULT_CATEGORIES: NCommon.CategoryConfig[] = [ - { - name: 'Camera', - location: 'Camera/', - patterns: [ - String.raw`^(IMG)_\d{8}_\d{6}(_\d{3})?(.*)\.(\w+)$`, - String.raw`^(S?VID)_\d{8}_\d{6}\.(\w+)$`, - ], - }, - { - name: 'Screenshot', - location: 'Screenshots/', - patterns: [ - String.raw`^((Watch)?[Ss]creenshot)_\d{8}_\d{6}(.*)\.(\w+)$`, - String.raw`^(S?VID)_\d{8}_\d{6}_\d+\.(\w+)$` - ], - }, - { - name: 'Misc', - location: 'Others/', - patterns: [ - String.raw`^(IMG)_(\d+)\.(\w+)$`, - String.raw`^(image)_(\d+)\.(\w+)$`, - String.raw`^(S?VID)_(\d+)\.(\w+)$`, - String.raw`^(alipay)_(\d+)\.(\w+)$`, - ], - }, - { - name: "Unmatched", - location: 'Others-unmatched/', - patterns: [".*"], - }, -]; - - type LOGIN_APP_PASSWORD = "login.appPassword"; type LOGIN_USERNAME = "login.username"; type LOGIN_SERVER = "login.server"; type UPLOAD_BASE_DIRECTORY = "upload.baseDir"; type UPLOAD_REPLACE_EXISTING = "upload.replaceExisting"; -type UPLOAD_CATEGORIES = "upload.categories"; type SETTINGS_KEYS = LOGIN_APP_PASSWORD | LOGIN_USERNAME | LOGIN_SERVER | UPLOAD_BASE_DIRECTORY | - UPLOAD_REPLACE_EXISTING | - UPLOAD_CATEGORIES; + UPLOAD_REPLACE_EXISTING; class NPreference { private pref: preferences.Preferences; @@ -85,19 +49,6 @@ class NPreference { this.setPref('upload.baseDir', dir); } - get categories(): NCommon.CategoryConfig[] { - let stringConfig = this.getPref('upload.categories', ""); - if (stringConfig === "") { - return DEFAULT_CATEGORIES; - } - return JSON.parse(stringConfig) as NCommon.CategoryConfig[]; - } - - set categories(categories: NCommon.CategoryConfig[]) { - let stringConfig = JSON.stringify(categories); - this.setPref('upload.categories', stringConfig); - } - public getPref(key: SETTINGS_KEYS, defaultValue: T): T { try { let value = this.pref.getSync(key, "") as T; @@ -126,17 +77,13 @@ class NPreference { export namespace NCommon { - export interface CategoryConfig { - name: string; - patterns: string[]; - location: string; - } - /** * Preferences for Nextcloud */ export let Preference: NPreference; + export let pHelper: photoAccessHelper.PhotoAccessHelper; + export function UserAgent() { return USER_AGENT_PART + deviceInfo.marketName + " (" + deviceInfo.productModel + ")"; } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index b122b16..45b405a 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -58,7 +58,12 @@ export class DBManager { columnsDef.push(colDef); if (colDesc.foreignKey) { let fk = colDesc.foreignKey; - let fkTargetColumn: string = Object.keys(fk.target.columns).find(key => fk.target.columns[key] === fk.targetColumn)!; + let fkTargetColumn: string = + Object.keys(fk.target.columns) + .find( + key => fk.target.columns[key] === + fk.targetColumn + )!; foreignKeyDefs.push(`FOREIGN KEY(${colName}) REFERENCES ${fk.target.tableName}(${fkTargetColumn})`); } } @@ -109,7 +114,10 @@ export class DBManager { let createSql = DBManager.generateTableCreationScript(tableDesc.tableName, tableDesc.columns); Logger.Info(TAG, `upgradeDatabase: creating table with sql: ${createSql}`); let result = await transaction.execute(createSql); - Logger.Info(TAG, `upgradeDatabase: created table ${tableDesc.tableName}, result: ${JSON.stringify(result)}`); + Logger.Info( + TAG, + `upgradeDatabase: created table ${tableDesc.tableName}, result: ${JSON.stringify(result)}` + ); } await transaction.execute('PRAGMA user_version = 1'); storeVersion = 1; @@ -177,11 +185,16 @@ export class DBManager { Logger.Error(TAG, `getObjects: failed to read result set: ${JSON.stringify(error)}`); } - Logger.Info(TAG, `getObjects: completed, found ${result.length} accounts`); + Logger.Info(TAG, `getObjects: completed, found ${result.length} items`); return result; } - async getItemById(id: number, column: ColumnDescriptor, factory: DBTableDescriptor): Promise { + async getItemById( + id: number, + column: ColumnDescriptor, + factory: DBTableDescriptor + ): Promise { Logger.Info(TAG, 'getItemById: enter'); if (!this.store) { Logger.Error(TAG, `getItemById: store is undefined`); @@ -226,9 +239,15 @@ export class DBManager { try { let deletedCount = await this.store.delete(predicate); - Logger.Info(TAG, `deleteItemById: completed, deleted ${deletedCount} rows from table ${object.getTableName()}`); + Logger.Info( + TAG, + `deleteItemById: completed, deleted ${deletedCount} rows from table ${object.getTableName()}` + ); } catch (error) { - Logger.Error(TAG, `deleteItemById: failed to delete from table ${object.getTableName()}: ${JSON.stringify(error)}`); + Logger.Error( + TAG, + `deleteItemById: failed to delete from table ${object.getTableName()}: ${JSON.stringify(error)}` + ); } } @@ -293,9 +312,10 @@ export class DBManager { try { let rowId = await this.store.insert(object.getTableName(), bucket); Logger.Info(TAG, `doStoreItem: completed, rowId: ${rowId}`); + this.store.commit(); return rowId; } catch (error) { - Logger.Error(TAG, `doStoreItem: failed to insert to table ${object.getTableName()}: ${JSON.stringify(error)}`); + Logger.Error(TAG, `doStoreItem: failed to insert to ${object.getTableName()}: ${JSON.stringify(error)}`); return; } } diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets index 16da938..efa5420 100644 --- a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets @@ -2,19 +2,30 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../../Utils/Logger'; import { DBItem, DBTableDescriptor } from '../DBManager'; -const TAG = 'Database/DBSyncFolder'; +const TAG = 'Database/DBLocalFolder'; + +export enum LocalFolderType { + Regular, + PhotoAlbum, +} export class DBLocalSyncFolder implements DBItem { static readonly DB_TABLE_NAME: string = 'LocalFolders'; id: number; localPath: string; alias: string; + folderType: LocalFolderType; - constructor(id: number, localPath: string, alias: string) { - Logger.Info(TAG, `constructor: enter, id=${id}, localPath=${localPath}, alias=${alias}`); + constructor(id: number, localPath: string, alias: string, folderType: LocalFolderType) { + Logger.Debug(TAG, `constructor: enter`, + `id: ${id}`, + `localPath: '${localPath}'`, + `alias: '${alias}'`, + `folderType: ${folderType}`); this.id = id; this.localPath = localPath; this.alias = alias; + this.folderType = folderType; } public getTableName(): string { @@ -28,6 +39,7 @@ export class DBLocalSyncFolder implements DBItem { } bucket['local_path'] = this.localPath; bucket['alias'] = this.alias; + bucket['folder_type'] = this.folderType; return bucket; } @@ -36,7 +48,7 @@ export class DBLocalSyncFolder implements DBItem { } } -type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias'; +type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'folder_type'; export const DBLocalSyncFolderTable: DBTableDescriptor = { tableName: DBLocalSyncFolder.DB_TABLE_NAME, @@ -44,12 +56,14 @@ export const DBLocalSyncFolderTable: DBTableDescriptor { + const offset = this.visibleItems.totalCount(); + let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, 50); + for (let item of items) { + this.visibleItems.addAsset(item); + } + } + + getAssets(start: number, length: number): PhotoItem[] { + let result: PhotoItem[] = []; + // Get from items if it's completely within range + if (start + length <= this.visibleItems.totalCount()) { + result = this.visibleItems.getDataRange(start, length); + } else if (start >= this.visibleItems.totalCount()) { + // Get from backedItems if it's completely within backedItems + let backedStart = start - this.visibleItems.totalCount(); + // for (let i = backedStart; i < Math.min(backedStart + length, this.invisibleBackedItems.size); i++) { + // result.push(this.invisibleBackedItems[i]); + // } + } else { + // Get from both items and backedItems + let itemsPartLength = this.visibleItems.totalCount() - start; + result = this.visibleItems.getDataRange(start, itemsPartLength); + let backedPartLength = length - itemsPartLength; + // for (let i = 0; i < Math.min(backedPartLength, this.invisibleBackedItems.size); i++) { + // result.push(this.invisibleBackedItems[i]); + // } + } + return result; + } + + // getTotalAvailableCount(): number { + // return this.visibleItems.totalCount() + this.invisibleBackedItems.size; + // } + // + // async uploadAllAssets(notify: boolean = true) { + // const BATCH_SIZE = 8; + // // const totalCount = this.getTotalAvailableCount(); + // // let total = Math.ceil(totalCount / BATCH_SIZE) * BATCH_SIZE; + // let totalUploaded = 0; + // for (let i = 0; i < total; i += BATCH_SIZE) { + // let batch = this.getAssets(i, BATCH_SIZE); + // + // let canStop = true; + // // + // // let futures = batch.map(asset => { + // // return asset.upload(NCommon.Preference.baseUploadDirectory + this.location).then((result) => { + // // canStop = canStop && result === UploadResult.EXISTED; + // // totalUploaded++; + // // }).then(() => { + // // if (notify) { + // // // NotificationManager.publishNotification('title', 'content', 1); + // // } + // // }); + // // }); + // + // // await Promise.all(futures); + // // if (canStop) { + // // if (notify) { + // // // NotificationManager.publishNotification('title', 'content', 1); + // // } + // // promptAction.showToast({ + // // message: `Upload is probably completed`, + // // duration: 2000, + // // }); + // // break; + // // } + // } + // } + + private matchesAsset(asset: PhotoItem): boolean { + return asset.ownerAlbumId === this.localFolder.id; + } +} diff --git a/entry/src/main/ets/models/ImageListDataSource.ets b/nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets similarity index 87% rename from entry/src/main/ets/models/ImageListDataSource.ets rename to nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets index bcb4f9b..669416d 100644 --- a/entry/src/main/ets/models/ImageListDataSource.ets +++ b/nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets @@ -1,4 +1,4 @@ -import { BasicDataSource } from 'nextcloud'; +import { BasicDataSource } from '../Utils/BasicDatasource'; import { PhotoItem } from './PhotoItem'; diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets new file mode 100644 index 0000000..06bd073 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -0,0 +1,145 @@ +import { Logger, NCommon } from '../Index'; +import { AddPosition, Category } from './Category'; +import { PhotoItem } from './PhotoItem'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { dataSharePredicates } from '@kit.ArkData'; +import { LocalFolder } from '../Database/Types/LocalFolder'; +import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; +import { Nextcloud } from '../Main'; + +const TAG = 'Utils/PhotoAssetsManager'; + +const OWNER_ALBUM_ID = 'owner_album_id'; + +@ObservedV2 +class PhotoAssetsManagerImpl { + private static readonly FETCH_COLUMNS = [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.PHOTO_TYPE, + photoAccessHelper.PhotoKeys.PHOTO_SUBTYPE, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DATE_ADDED_MS, + photoAccessHelper.PhotoKeys.SIZE, + photoAccessHelper.PhotoKeys.WIDTH, + photoAccessHelper.PhotoKeys.HEIGHT, + photoAccessHelper.PhotoKeys.TITLE, + photoAccessHelper.PhotoKeys.COVER_POSITION, + photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, + photoAccessHelper.PhotoKeys.DETAIL_TIME, + photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, + OWNER_ALBUM_ID, // API22 + ]; + @Trace public categories: Map = new Map(); + + private async getAllCategories(): Promise { + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + OWNER_ALBUM_ID, + ], + predicates: new dataSharePredicates.DataSharePredicates() + .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) + }; + + let albums: number[] = []; + try { + let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + while (!result.isAfterLast()) { + const photoItem = await result.getNextObject(); + const albumId = photoItem.get(OWNER_ALBUM_ID) as number; + if (!albums.includes(albumId)) { + albums.push(albumId); + const displayName = photoItem.get(photoAccessHelper.PhotoKeys.DISPLAY_NAME); + Logger.Debug(TAG, `found albumId: ${albumId} via '${displayName}'`); + } + } + } catch (e) { + Logger.Info(TAG, `failed to get all photos: ${JSON.stringify(e)}`); + } + albums.sort((a, b) => a - b); + return albums; + } + + public async loadNextBatchFromAlbumId(albumId: number, offset: number, limit: number): Promise { + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates() + .equalTo(OWNER_ALBUM_ID, albumId.toString()) + .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) + .limit(limit, offset) + }; + + let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + let assets = await result.getAllObjects(); + Logger.Info(TAG, `loadNextBatchFromAlbumId:`, + `albumId: ${albumId}`, + `offset: ${offset}`, + `limit: ${limit} -> ${assets.length} assets` + ); + let items: PhotoItem[] = []; + for (let asset of assets) { + items.push(new PhotoItem(asset)); + } + return items; + } + + public async initialize(categories: Map) { + for (let item of categories) { + Logger.Debug(TAG, `existing id: ${item[1].albumId}`) + let localFolder = item[1]; + if (localFolder.folderType === LocalFolderType.PhotoAlbum) { + this.categories.set(localFolder.albumId, new Category(localFolder)); + } + } + + const sfm = Nextcloud.getSyncFolderManager(); + const albums = await this.getAllCategories(); + for (let albumId of albums) { + if (!this.categories.has(albumId)) { + Logger.Warn(TAG, `found new AlbumId: ${albumId}`); + let localFolder = await sfm.addLocalAlbumFolder(albumId, `Album ${albumId}`); + this.categories.set(localFolder.id, new Category(localFolder)); + } + } + } + + public async prependNewAsset(assetUri: string) { + Logger.Info(TAG, `prependNewAsset enter, uri: ${assetUri}`); + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates() + .equalTo(photoAccessHelper.PhotoKeys.URI, assetUri), + }; + + try { + let assetResult = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + if (assetResult.getCount() !== 1) { + Logger.Error(TAG, `found more than one asset with uri: ${assetUri}`); + return; + } + + let asset = await assetResult.getFirstObject(); + let photoItem = new PhotoItem(asset); + + Logger.Info(TAG, `prepending new asset: ${photoItem.displayName}, uri: ${photoItem.uri}`); + this.doAddPhotoItem(photoItem, AddPosition.Prepend); + assetResult.close(); + } catch (error) { + Logger.Error(TAG, `failed to add new asset: ${error}`); + } + } + + private async doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { + let ownerAlbumId = photoItem.ownerAlbumId; + let category = this.categories.get(ownerAlbumId); + if (category) { + category.tryAddAsset(photoItem, addPosition); + } + Logger.Error(TAG, `XXX: add new category for ${ownerAlbumId} failed`); + } +} + +export const PhotoAssetsManager = new PhotoAssetsManagerImpl(); diff --git a/entry/src/main/ets/models/PhotoItem.ets b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets similarity index 85% rename from entry/src/main/ets/models/PhotoItem.ets rename to nextcloud/src/main/ets/SyncEngine/PhotoItem.ets index 0e0662b..0b57550 100644 --- a/entry/src/main/ets/models/PhotoItem.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets @@ -1,8 +1,8 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { CreatePath, Logger, Nextcloud, UploadFile } from 'nextcloud'; +import { CreatePath, Logger, Nextcloud, UploadFile } from '../Index'; import { promptAction } from '@kit.ArkUI'; -import { FormatTime, ShowToast } from '../utils/Utils'; import { image } from '@kit.ImageKit'; +import { FormatTime } from '../Utils/CommonUtils'; const TAG = 'PhotoItem'; const DEFAULT_FORMAT = 'yyyy/MM/'; @@ -25,11 +25,6 @@ export enum UploadResult { export class PhotoItem { uploadStatus: UploadStatus = UploadStatus.None; private innerAsset: PHelper.PhotoAsset; - // BEGIN hack - private readonly activeAccountId: number = 0; - private readonly account = Nextcloud.getAccountById(this.activeAccountId); - - // END hack constructor(asset: PHelper.PhotoAsset) { this.innerAsset = asset; @@ -67,14 +62,21 @@ export class PhotoItem { return this.photoType === PHelper.PhotoType.VIDEO; } + get ownerAlbumId(): number { + return Number(this.innerAsset.get('owner_album_id')); + } + async getThumbnail(size?: image.Size): Promise { return await this.innerAsset.getThumbnail(size); } async upload(targetPath: string): Promise { + const activeAccountId: number = 0; + const account = Nextcloud.getAccountById(activeAccountId); + this.uploadStatus = UploadStatus.Uploading; let path = targetPath + FormatTime(this.dateTaken, DEFAULT_FORMAT); - let mkdirResult = await CreatePath(this.account, path); + let mkdirResult = await CreatePath(account, path); if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { // 405 is the status code for "already exists" Logger.Error(TAG, `Failed to create path: ${path}`); @@ -87,7 +89,7 @@ export class PhotoItem { let ret: UploadResult = UploadResult.FAILED; let message = ""; - let uploadResult = await UploadFile(this.account, this.displayName, this.dateTakenMs, path, this.uri); + let uploadResult = await UploadFile(account, this.displayName, this.dateTakenMs, path, this.uri); if (uploadResult !== 201 && uploadResult !== 204) { message = `Failed: ${this.displayName}: ${uploadResult}`; this.uploadStatus = UploadStatus.Failed; diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 4a93a2d..a5403fa 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -4,7 +4,7 @@ import { DBManager } from '../Database/DBManager'; import { DBSyncConfiguration, DBSyncConfigurationTable } from '../Database/DbTypes/DBSyncConfiguration'; import { LocalFolder } from '../Database/Types/LocalFolder'; import { RemoteFolder } from '../Database/Types/RemoteFolder'; -import { DBLocalSyncFolder, DBLocalSyncFolderTable } from '../Database/DbTypes/DBLocalFolder'; +import { DBLocalSyncFolder, DBLocalSyncFolderTable, LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; import { DBRemoteFolder, DBRemoteFolderTable } from '../Database/DbTypes/DBRemoteFolder'; import { Account } from '../Account/Account'; import { SyncConfiguration } from '../Database/Types/SyncConfiguration'; @@ -23,6 +23,10 @@ export class SyncFolderManager { this.dbManager_ = dbManager; } + public get allLocalFolders(): Map { + return this.localFolders_; + } + public get allSyncConfigurations(): Map { return this.configurations_; } @@ -31,16 +35,27 @@ export class SyncFolderManager { Logger.Info(TAG, `startWatchForChanges: enter`); } + public async addLocalAlbumFolder(albumId: number, alias: string): Promise { + Logger.Info(TAG, `addLocalFolder: enter`); + let dbLocalFolder = new DBLocalSyncFolder(0, albumId.toString(), alias, LocalFolderType.PhotoAlbum); + let localFolder = new LocalFolder(dbLocalFolder); + let insertedId = await this.dbManager_.createItem(dbLocalFolder); + dbLocalFolder.id = insertedId!; + this.localFolders_.set(dbLocalFolder.id, localFolder); + Logger.Info(TAG, `addLocalFolder: added local folder id ${dbLocalFolder.id} with albumId ${albumId}`); + return localFolder; + } + public async addLocalFolder(localPath: string, alias: string): Promise { Logger.Info(TAG, `addLocalFolder: enter`); - let dbLocalFolder = new DBLocalSyncFolder(0, localPath, alias); + let dbLocalFolder = new DBLocalSyncFolder(0, localPath, alias, LocalFolderType.Regular); let localFolder = new LocalFolder(dbLocalFolder); let insertedId = await this.dbManager_.createItem(dbLocalFolder); dbLocalFolder.id = insertedId!; this.localFolders_.set(dbLocalFolder.id, localFolder); Logger.Info(TAG, `addLocalFolder: added local folder id ${dbLocalFolder.id} with path ${localPath}`); return localFolder; - } + }; public async addRemoteFolder(account: Account, remotePath: string, alias: string): Promise { Logger.Info(TAG, `addRemoteFolder: enter`); @@ -53,7 +68,10 @@ export class SyncFolderManager { return remoteFolder; } - public async addSyncConfiguration(localFolder: LocalFolder, remoteFolder: RemoteFolder): Promise { + public async addSyncConfiguration( + localFolder: LocalFolder, + remoteFolder: RemoteFolder + ): Promise { Logger.Info(TAG, `addSyncConfiguration: enter`); let dbSyncConfiguration = new DBSyncConfiguration(0, localFolder.id, remoteFolder.id); let insertedId = await this.dbManager_.createItem(dbSyncConfiguration); @@ -61,7 +79,10 @@ export class SyncFolderManager { const syncConfig = new SyncConfiguration(localFolder, remoteFolder, dbSyncConfiguration); this.configurations_.set(dbSyncConfiguration.id, syncConfig); Logger.Info(TAG, `${JSON.stringify(this.configurations_.get(dbSyncConfiguration.id))}`); - Logger.Info(TAG, `addSyncConfiguration: added configuration id ${insertedId} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}`); + Logger.Info( + TAG, + `addSyncConfiguration: added configuration id ${insertedId} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}` + ); return syncConfig; } @@ -74,18 +95,34 @@ export class SyncFolderManager { public async loadSyncFolders(): Promise { Logger.Info(TAG, `loadSyncFolders: enter`); + let localFolders = await this.dbManager_.getItems(DBLocalSyncFolderTable); + for (let localFolder of localFolders) { + this.localFolders_.set(localFolder.id, new LocalFolder(localFolder)); + } + + let remoteFolders = await this.dbManager_.getItems(DBRemoteFolderTable); + for (let remoteFolder of remoteFolders) { + this.remoteFolders_.set(remoteFolder.id, new RemoteFolder(remoteFolder)); + } + let dbSyncConfiguration = await this.dbManager_.getItems(DBSyncConfigurationTable); for (let conf of dbSyncConfiguration) { const localFolder = await this.getLocalFolderById(conf.localFolderId); const remoteFolder = await this.getRemoteFolderById(conf.remoteFolderId); if (!localFolder || !remoteFolder) { - Logger.Error(TAG, `loadSyncFolders: failed to load sync configuration id ${conf.id} due to missing local or remote folder`); + Logger.Error( + TAG, + `loadSyncFolders: failed to load sync configuration id ${conf.id} due to missing local or remote folder` + ); continue; } const syncConfig = new SyncConfiguration(localFolder, remoteFolder, conf); this.configurations_.set(conf.id, syncConfig); - Logger.Info(TAG, `loadSyncFolders: loaded sync configuration id ${conf.id} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}`); + Logger.Info( + TAG, + `loadSyncFolders: loaded sync configuration id ${conf.id} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}` + ); } Logger.Info(TAG, `loadSyncFolders: loaded total ${this.configurations_.size} sync configurations`); } @@ -96,11 +133,14 @@ export class SyncFolderManager { return existing; } - let dbLocalFolder = await this.dbManager_.getItemById(id, DBLocalSyncFolderTable.columns.folder_id, DBLocalSyncFolderTable); + let dbLocalFolder = + await this.dbManager_.getItemById(id, DBLocalSyncFolderTable.columns.folder_id, DBLocalSyncFolderTable); if (!dbLocalFolder) { Logger.Error(TAG, `getLocalFolderById: local folder id ${id} not found`); return null; } + + Logger.Info(TAG, `getLocalFolderById: ${dbLocalFolder.id}`); existing = new LocalFolder(dbLocalFolder); this.localFolders_.set(id, existing); return existing; @@ -112,7 +152,12 @@ export class SyncFolderManager { return existing; } - let dbRemoteFolder = await this.dbManager_.getItemById(id, DBRemoteFolderTable.columns.folder_id, DBRemoteFolderTable); + let dbRemoteFolder = + await this.dbManager_.getItemById( + id, + DBRemoteFolderTable.columns.folder_id, + DBRemoteFolderTable + ); if (!dbRemoteFolder) { Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); return null; diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManager.ets b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets index 70192a3..844b98b 100644 --- a/nextcloud/src/main/ets/SyncEngine/TransferManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets @@ -1,20 +1,28 @@ import { BasicDataSource } from '../Utils/BasicDatasource'; +import { Logger } from '../Utils/Logger'; +import { + ITransferTask, + publishTransferEvent, + TransferManagerEventCode, + TransferType, +} from './TransferManagerEventIPC'; -export enum TransferType { - DOWNLOAD, - UPLOAD, -} +const TAG = 'SyncEngine/TransferManager'; -@Observed +@ObservedV2 export class TransferTask { - public readonly type: TransferType; - public readonly filename: string; - public totalSize: number = 1; - public transferredSize: number = 0; - - constructor(type: TransferType, filename: string) { - this.type = type; - this.filename = filename; + readonly id: number; + @Trace public readonly type: TransferType; + @Trace public readonly filename: string; + @Trace public totalSize: number = 1; + @Trace public transferredSize: number = 0; + + constructor(task: ITransferTask) { + this.id = task.id; + this.type = task.type; + this.filename = task.filename; + this.totalSize = task.totalSize; + this.transferredSize = task.transferredSize; } public get progressPercentText() { @@ -25,17 +33,47 @@ export class TransferTask { public updateProgress(transferredSize: number, totalSize: number): number { if (this.totalSize != totalSize) { this.totalSize = totalSize; + publishTransferEvent({ + code: TransferManagerEventCode.TASK_CREATE, + taskId: this.id, + task: { + id: this.id, + type: this.type, + filename: this.filename, + totalSize: this.totalSize, + transferredSize: this.transferredSize + }, + }) + } + + if (transferredSize !== transferredSize) { + Logger.Warn(TAG, `transferred size not changed`); + } else { + this.transferredSize = transferredSize; + + if (this.transferredSize !== this.totalSize) { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_PROGRESS, + taskId: this.id, + transferredSize: this.transferredSize + }) + } else { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_COMPLETED, + taskId: this.id, + }) + } } - this.transferredSize = transferredSize; return this.transferredSize / this.totalSize; } } -@Observed export class TransferManagerImpl { private tasks_: BasicDataSource; + private idSeq_ = 0; constructor() { + Logger.Info(TAG, `created TransferManager`); this.tasks_ = new BasicDataSource(); } @@ -44,10 +82,10 @@ export class TransferManagerImpl { } public addTransferTask(type: TransferType, fileName: string): TransferTask { - let task = new TransferTask(type, fileName); + let id = this.idSeq_++; + let task = new TransferTask({ id, type, filename: fileName, transferredSize: 0, totalSize: 0 }); return this.tasks_.prependItem(task); } } - export let TransferManager = new TransferManagerImpl(); diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets new file mode 100644 index 0000000..ca4d4b1 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets @@ -0,0 +1,65 @@ +import { commonEventManager } from "@kit.BasicServicesKit"; +import { Logger } from "../Index"; + +const TAG = 'SyncEngine/TransferManager/IPC'; + +export enum TransferType { + DOWNLOAD, + UPLOAD, +} + +export interface ITransferTask { + id: number; + type: TransferType; + filename: string; + totalSize: number; + transferredSize: number; +} + +export enum TransferManagerEventCode { + TASK_CREATE = 1, + TASK_PROGRESS = 2, + TASK_COMPLETED = 3, + TASK_FAILED = 4, +} + +interface TransferManagerEvent_TaskCreate { + code: TransferManagerEventCode.TASK_CREATE; + taskId: number; + task: ITransferTask; +}; + +interface TransferManagerEvent_TaskProgress { + code: TransferManagerEventCode.TASK_PROGRESS; + taskId: number; + transferredSize: number; +}; + +interface TransferManagerEvent_TaskCompleted { + code: TransferManagerEventCode.TASK_COMPLETED; + taskId: number; +}; + +interface TransferManagerEvent_TaskFailed { + code: TransferManagerEventCode.TASK_FAILED; + taskId: number; +}; + +export type TransferManagerEvent = + TransferManagerEvent_TaskCreate + | TransferManagerEvent_TaskProgress + | TransferManagerEvent_TaskCompleted + | TransferManagerEvent_TaskFailed; + +export const TRANSFER_MANAGER_COMMON_EVENT_NAME: string = 'Nextcloud.TransferManager.Events'; + +function noop() { +} + +export function publishTransferEvent(event: TransferManagerEvent) { + let options: commonEventManager.CommonEventPublishData = { + data: JSON.stringify(event), + }; + Logger.Debug(TAG, `published transfer event: ${options.data}`); + commonEventManager.publish(TRANSFER_MANAGER_COMMON_EVENT_NAME, options, noop); +} diff --git a/nextcloud/src/main/ets/Utils/CommonUtils.ets b/nextcloud/src/main/ets/Utils/CommonUtils.ets index 44287f0..dccba62 100644 --- a/nextcloud/src/main/ets/Utils/CommonUtils.ets +++ b/nextcloud/src/main/ets/Utils/CommonUtils.ets @@ -14,3 +14,19 @@ export async function AsyncSleep(ms: number): Promise { export function Base64Encode(msg: string): string { return Base64Util.encodeToStringSync(Utf8Encoder.encodeInto(msg)); } + +export function FormatTime(date: Date, format: string) { + // make sure the path format ends with a slash + if (!format.endsWith('/')) { + format += '/'; + } + + let result = format; + result = result.replace('yyyy', date.getFullYear().toString()); + result = result.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0')); + result = result.replace('dd', date.getDate().toString().padStart(2, '0')); + result = result.replace('HH', date.getHours().toString().padStart(2, '0')); + result = result.replace('mm', date.getMinutes().toString().padStart(2, '0')); + result = result.replace('ss', date.getSeconds().toString().padStart(2, '0')); + return result; +} diff --git a/nextcloud/src/main/ets/Utils/Logger.ts b/nextcloud/src/main/ets/Utils/Logger.ts index 9a02dc1..a85f98a 100644 --- a/nextcloud/src/main/ets/Utils/Logger.ts +++ b/nextcloud/src/main/ets/Utils/Logger.ts @@ -4,19 +4,19 @@ import hilog from '@ohos.hilog' const DOMAIN = 0xABAB; export class Logger { - static Debug(component: string, message: string) { - hilog.debug(DOMAIN, component, message) - } + static Debug(component: string, ...message: string[]) { + hilog.debug(DOMAIN, component, message.join(', ')) + } - static Info(component: string, message: string) { - hilog.info(DOMAIN, component, message) - } + static Info(component: string, ...message: string[]) { + hilog.info(DOMAIN, component, message.join(', ')) + } - static Warn(component: string, message: string) { - hilog.warn(DOMAIN, component, message) - } + static Warn(component: string, ...message: string[]) { + hilog.warn(DOMAIN, component, message.join(', ')) + } - static Error(component: string, message: string) { - hilog.error(DOMAIN, component, message) - } + static Error(component: string, ...message: string[]) { + hilog.error(DOMAIN, component, message.join(', ')) + } } diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets index 7fb2e73..e2d86a5 100644 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ b/nextcloud/src/main/ets/webdav/Uploader.ets @@ -2,12 +2,16 @@ import { rcp } from '@kit.RemoteCommunicationKit'; import { NCommon } from '../Common'; import fs from '@ohos.file.fs'; import { Logger } from '../Utils/Logger'; -import { TransferManager, TransferType } from '../SyncEngine/TransferManager'; +import { TransferManager } from '../SyncEngine/TransferManager'; import { Account } from '../Account/Account'; +import { TransferType } from '../SyncEngine/TransferManagerEventIPC'; const TAG = "webdav/Upload"; -function CreateSession(account: Account, extraHeaders?: Map, eventsHandler?: rcp.HttpEventsHandler): rcp.Session { +function CreateSession( + account: Account, + extraHeaders?: Map, + eventsHandler?: rcp.HttpEventsHandler): rcp.Session { let headers: rcp.RequestHeaders = {}; if (extraHeaders !== undefined) { @@ -37,7 +41,11 @@ function CreateSession(account: Account, extraHeaders?: Map, eve } } -export async function Exists(session: rcp.Session, account: Account, path: string, filename: string = ""): Promise { +export async function Exists( + session: rcp.Session, + account: Account, + path: string, + filename: string = ""): Promise { let fullPath = account.remoteBaseDirUrl() + path + "/" + filename; Logger.Info(TAG, "Checking if path exists: " + fullPath); @@ -95,11 +103,20 @@ export async function CreatePath(account: Account, path: string): Promise { - let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, relativePath); + let parts = relativePath.split('/'); + let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, parts[parts.length - 1]); + + let transferredSize = 0; + let totalSize = 0; + + let timerId = setInterval(() => { + task.updateProgress(transferredSize, totalSize); + }, 500); let eventsHandler: rcp.HttpEventsHandler = { onDownloadProgress: (totalBytes: number, transferredBytes: number) => { - task.updateProgress(transferredBytes, totalBytes); + transferredSize = transferredBytes; + totalSize = totalBytes; } }; @@ -110,15 +127,21 @@ export async function DownloadFile(account: Account, relativePath: string, local }; await session.downloadToFile(account.remoteBaseDirUrl() + relativePath, downloadToFileOptions).then(() => { - task.updateProgress(task.totalSize, task.totalSize); + clearInterval(timerId); + task.updateProgress(totalSize, totalSize); }).catch((error: BusinessError) => { Logger.Error(TAG, `Download failed: ${relativePath}, error: ${JSON.stringify(error)}`); }); - + clearInterval(timerId); session.close(); } -export async function UploadFile(account: Account, filename: string, mTime: number, path: string, uri: string): Promise { +export async function UploadFile( + account: Account, + filename: string, + mTime: number, + path: string, + uri: string): Promise { let extraHeaders = new Map(); extraHeaders.set('X-OC-MTime', `${Math.round(mTime / 1000)}`); let session = CreateSession(account, extraHeaders); diff --git a/oh-package.json5 b/oh-package.json5 index f10aa8e..815f06d 100644 --- a/oh-package.json5 +++ b/oh-package.json5 @@ -1,10 +1,10 @@ { - "modelVersion": "5.0.1", + "modelVersion": "6.0.0", "description": "Nextcloud Client for HarmonyOS", "dependencies": { }, "devDependencies": { "@ohos/hypium": "1.0.19", - "@ohos/hamock": "1.0.0", + "@ohos/hamock": "1.0.0" } -} +} \ No newline at end of file From 5e4b9ba2608b5a1613cb75ee715da9b198a809fe Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sun, 25 Jan 2026 03:28:55 +0800 Subject: [PATCH 11/16] uploader & job manager --- entry/patch.json | 2 +- .../main/ets/entryability/EntryAbility.ets | 45 ++++- entry/src/main/ets/pages/PcMainPage.ets | 52 ++--- entry/src/main/ets/pages/UploadPage.ets | 92 +++++---- .../main/ets/statusbar/StatusBarAbility.ets | 2 +- .../src/main/ets/statusbar/StatusBarPage.ets | 82 ++------ .../ets/statusbar/TransferStatusReceiver.ets | 83 ++++++++ entry/src/main/ets/utils/DownloadQueue.ets | 4 +- .../main/ets/utils/NotificationManager.ets | 76 +++---- nextcloud/src/main/ets/Account/Account.ets | 4 + nextcloud/src/main/ets/Common.ets | 10 + .../ets/Database/Types/LocalAlbumFolder.ets | 11 ++ .../main/ets/Database/Types/LocalFolder.ets | 20 +- .../main/ets/Database/Types/RemoteFolder.ets | 20 +- nextcloud/src/main/ets/Index.ets | 20 +- nextcloud/src/main/ets/Jobs/BaseJob.ets | 15 -- .../src/main/ets/SyncEngine/Category.ets | 53 ++--- .../src/main/ets/SyncEngine/Jobs/BaseJob.ets | 64 ++++++ .../SyncEngine/Jobs/DownloadTransferJob.ets | 28 +++ .../main/ets/{ => SyncEngine}/Jobs/Index.ets | 0 .../ets/{ => SyncEngine}/Jobs/MkColJob.ets | 0 .../main/ets/SyncEngine/Jobs/TransferJob.ets | 144 ++++++++++++++ .../ets/SyncEngine/Jobs/UploadTransferJob.ets | 124 ++++++++++++ .../ets/SyncEngine/PhotoAssetsManager.ets | 76 +++---- .../src/main/ets/SyncEngine/PhotoItem.ets | 28 ++- .../main/ets/SyncEngine/SyncFolderManager.ets | 14 +- .../main/ets/SyncEngine/TransferManager.ets | 97 +++------ .../SyncEngine/TransferManagerEventIPC.ets | 24 +-- nextcloud/src/main/ets/Utils/Logger.ts | 11 +- .../src/main/ets/webdav/TransferUtils.ets | 110 +++++++++++ nextcloud/src/main/ets/webdav/Uploader.ets | 186 ------------------ 31 files changed, 906 insertions(+), 591 deletions(-) create mode 100644 entry/src/main/ets/statusbar/TransferStatusReceiver.ets create mode 100644 nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets delete mode 100644 nextcloud/src/main/ets/Jobs/BaseJob.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets rename nextcloud/src/main/ets/{ => SyncEngine}/Jobs/Index.ets (100%) rename nextcloud/src/main/ets/{ => SyncEngine}/Jobs/MkColJob.ets (100%) create mode 100644 nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets create mode 100644 nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets create mode 100644 nextcloud/src/main/ets/webdav/TransferUtils.ets delete mode 100644 nextcloud/src/main/ets/webdav/Uploader.ets diff --git a/entry/patch.json b/entry/patch.json index cdb9276..5b8c160 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2000954, + "patchVersionCode": 2000984, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 1055944..ee8d39e 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { AbilityConstant, ConfigurationConstant, UIAbility, Want, wantAgent } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; import { AsyncSleep, InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; @@ -7,6 +7,8 @@ import { NotificationManager } from '../utils/NotificationManager'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { deviceInfo } from '@kit.BasicServicesKit'; import { statusBarManager } from '@kit.DeskTopExtensionKit'; +import { BUNDLE_NAME } from 'BuildProfile'; +import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'; const TAG = 'EntryAbility'; @@ -34,14 +36,6 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Ability onDestroy'); } - private async ensureInitCompleted() { - while (!this.initCompleted) { - Logger.Info(TAG, 'Waiting for initialization to complete...'); - await AsyncSleep(100); - } - Logger.Info(TAG, 'Initialization completed, proceeding to create main window.'); - } - async onWindowStageCreate(windowStage: window.WindowStage) { await this.ensureInitCompleted(); let context = this.context; @@ -49,7 +43,7 @@ export default class EntryAbility extends UIAbility { try { let page = this.isPc ? 'pages/PcMainPage' : 'pages/Index'; - await NotificationManager.startNotification(this.context); + await this.startBackgroundRunning(); NCommon.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); await windowStage.loadContent(page); if (this.isPc) { @@ -99,6 +93,37 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, `want: ${JSON.stringify(want)}, ${JSON.stringify(launchParam)}`); } + private async startBackgroundRunning() { + Logger.Info(TAG, `startBackgroundRunning`); + try { + const WANT_AGENT_INFO: wantAgent.WantAgentInfo = { + wants: [{ bundleName: BUNDLE_NAME, abilityName: "EntryAbility" }], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }; + let wantAgentObj = await wantAgent.getWantAgent(WANT_AGENT_INFO); + let req = new backgroundTaskManager.ContinuousTaskRequest(); + req.backgroundTaskModes = [backgroundTaskManager.BackgroundTaskMode.MODE_TASK_KEEPING]; + req.backgroundTaskSubmodes = [backgroundTaskManager.BackgroundTaskSubmode.SUBMODE_NORMAL_NOTIFICATION]; + req.wantAgent = wantAgentObj; + + let res = await backgroundTaskManager.startBackgroundRunning(this.context, req); + Logger.Info(TAG, `startBackgroundRunning: succeeded: ${JSON.stringify(res)}`); + } catch (error) { + let e = error as BusinessError; + Logger.Error(TAG, `startBackgroundRunning: failed. code is ${e.code} message is ${e.message}`); + } + } + + private async ensureInitCompleted() { + while (!this.initCompleted) { + Logger.Info(TAG, 'Waiting for initialization to complete...'); + await AsyncSleep(100); + } + Logger.Info(TAG, 'Initialization completed, proceeding to create main window.'); + } + /** * Convert Resource to PixelMap * @throws diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 0558917..607d1a8 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -1,15 +1,14 @@ import { Account, DirectoryEntry, - DownloadFile, + JobManager, ListFiles, Logger, Nextcloud, + PhotoAssetsManager, SyncConfiguration, SyncFolderManager, - TransferManager, - TransferManagerImpl, - TransferTask, + TransferJob } from 'nextcloud'; import { common } from '@kit.AbilityKit'; import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; @@ -18,31 +17,31 @@ import { PermissionManager } from '../utils/PermissionManager'; import { LengthMetrics } from '@kit.ArkUI'; import { DownloadQueue } from '../utils/DownloadQueue'; import { SettingsPage } from './SettingsPage'; -import { PhotoAssetsManager } from 'nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager'; import { UploadPage } from './UploadPage'; import { emitter } from '@kit.BasicServicesKit'; import { Constants } from '../utils/Common'; +import { PhotoMonitor } from '../utils/PhotoMonitor'; const TAG = 'PcAbility' const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; @ComponentV2 struct TransferProgressItem { - @Require @Param transferTask: TransferTask; + @Require @Param job: TransferJob; build() { Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center, space: { main: LengthMetrics.vp(8) } }) { - Text(this.transferTask.filename) + Text(this.job.filename) .width(180) .textOverflow({ overflow: TextOverflow.MARQUEE }) .textAlign(TextAlign.End) Progress({ - value: this.transferTask.transferredSize, - total: this.transferTask.totalSize, + value: this.job.transferredSize, + total: this.job.totalSize, }) - Text(this.transferTask.progressPercentText) + Text(this.job.progressPercentText) .width(100) } } @@ -53,7 +52,6 @@ struct SyncConfigurationRow { @Require @Param readonly syncFolderManager: SyncFolderManager; @Require @Param readonly configuration: SyncConfiguration; @Require @Param readonly account: Account; - private downloadQueue: DownloadQueue = new DownloadQueue(); build() { Row({ space: 8 }) { @@ -92,9 +90,11 @@ struct SyncConfigurationRow { private async doDownloadAll() { Logger.Info(TAG, `sync all`); - this.downloadQueue.addRootDir(this.account); - while (this.downloadQueue.hasPendingItem()) { - let next = await this.downloadQueue.getNextDownloadFile(); + + const downloadQueue: DownloadQueue = new DownloadQueue(); + downloadQueue.addRootDir(this.account); + while (downloadQueue.hasPendingItem()) { + let next = await downloadQueue.getNextDownloadFile(); if (next === undefined) { break; } @@ -105,13 +105,15 @@ struct SyncConfigurationRow { } catch (e) { } } else { - await DownloadFile( - this.account, - next.relativePath, - this.configuration.localFolder.localPath + - '/' + - next.relativePath - ); + const fileName = next.relativePath.split('/').pop() || ''; + let transferJob = JobManager.addDownloadJob({ + fileName: fileName, + localFolder: this.configuration.localFolder, + remoteFolder: this.configuration.remoteFolder, + relativePath: next.relativePath + }); + transferJob.start(); + break; } Logger.Info(TAG, next.relativePath); } @@ -127,7 +129,6 @@ struct PCAbility { @Local private opHistory: CloudSyncApi.FileChangeInfo[] = []; @Local private files: DirectoryEntry[] = []; @Local private syncFolderManager: SyncFolderManager = Nextcloud.getSyncFolderManager(); - @Local private transferManager: TransferManagerImpl = TransferManager; private pendingMoveTo: Map = new Map(); @Builder @@ -194,13 +195,13 @@ struct PCAbility { Text('download progress') List() { - LazyForEach(this.transferManager.tasks, (item: TransferTask) => { + LazyForEach(JobManager.tasks, (item: TransferJob) => { ListItem() { TransferProgressItem({ - transferTask: item + job: item }) } - }, (item: TransferTask, _index: number) => { + }, (item: TransferJob, _index: number) => { return `${item.id}` }) } @@ -242,6 +243,7 @@ struct PCAbility { ]); PhotoAssetsManager.initialize(this.syncFolderManager.allLocalFolders); + PhotoMonitor.beginMonitorPhotoChange(); for (let conf of this.syncFolderManager.allSyncConfigurations.values()) { Logger.Info(TAG, `conf: ${conf.id} - ${conf.localFolder.localPath} <=> ${conf.remoteFolder.remotePath}`); CloudSyncApi.connect(conf.localFolder.localPath); diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 7ccd01b..360d44c 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,17 +1,13 @@ -import { Logger, NCommon } from 'nextcloud'; -import { Category } from 'nextcloud/src/main/ets/SyncEngine/Category'; +import { Category, Logger, PhotoAssetsManager, PhotoItem, UploadStatus } from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; -import { PhotoItem, UploadStatus } from 'nextcloud/src/main/ets/SyncEngine/PhotoItem'; -import { PhotoAssetsManager } from 'nextcloud'; const TAG = "Page-UploadPage" - -@Component +@ComponentV2 struct PhotoView { - @ObjectLink asset: PhotoItem; - @ObjectLink category: Category; - @State private thumbnail: PixelMap | null = null; + @Require @Param asset: PhotoItem; + @Require @Param category: Category; + @Local private thumbnail: PixelMap | null = null; getUploadColor(status: UploadStatus): Color { switch (status) { @@ -48,7 +44,7 @@ struct PhotoView { .borderWidth(3) .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { - // await this.asset.upload(NCommon.Preference.baseUploadDirectory + this.category.location); + await this.asset.upload("/instantupload/category/"); }) .onAppear(async () => { if (this.thumbnail === null) { @@ -58,28 +54,31 @@ struct PhotoView { } } -@Component -struct PhotosGridView { - @ObjectLink category: Category; - private scroller: Scroller = new Scroller(); +@Preview +@ComponentV2 +export struct UploadPage { + @Local private categories: Map = PhotoAssetsManager.categories; - build() { + @Builder + photosGridView(category: Category) { Stack() { - Grid(this.scroller) { - LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { + Grid() { + LazyForEach(category.visibleItems, (asset: PhotoItem, index: number) => { GridItem() { - PhotoView({ asset: asset, category: this.category }) + PhotoView({ asset: asset, category: category }) } .width('100%') .onAppear(() => { - if (index == this.category.visibleItems.totalCount() - 1) { - this.category.loadNextBatch(); + if (index == category.visibleItems.totalCount() - 1) { + category.loadNextBatch(); } }) }) } + .layoutDirection(GridDirection.Row) .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) }) - .columnsTemplate('1fr 1fr 1fr 1fr') + // .columnsTemplate('1fr 1fr 1fr 1fr') + .columnsTemplate('repeat(auto-fit, 100)') .scrollBar(BarState.On) .edgeEffect(EdgeEffect.Spring) .columnsGap(8) @@ -91,7 +90,7 @@ struct PhotosGridView { Column() { Button($r('app.string.upload_all')) .onClick(() => { - Logger.Info(TAG, `Upload all in '${this.category.name}'`); + Logger.Info(TAG, `Upload all in '${category.name}'`); // this.category.uploadAllAssets(); }) .width('30%') @@ -100,39 +99,38 @@ struct PhotosGridView { .position({ bottom: 5 }) } } -} - - -@Preview -@ComponentV2 -export struct UploadPage { - @Local private categories: Map = PhotoAssetsManager.categories; build() { Tabs() { ForEach(Array.from(this.categories), (category: [number, Category]) => { - TabContent() { - Flex({ direction: FlexDirection.Column, space: { main: LengthMetrics.px(20) } }) { - Flex({ - direction: FlexDirection.Row, - justifyContent: FlexAlign.SpaceBetween, - alignItems: ItemAlign.Baseline, - }) { - Text(category[1].name) - .fontWeight(FontWeight.Bold) - .fontSize(24) + if (category[1].visibleItems.totalCount() >= 10) { + TabContent() { + Flex({ direction: FlexDirection.Column, space: { main: LengthMetrics.px(20) } }) { + Row({ space: 16 }) { + TextInput({ text: category[1].name }) + .fontWeight(FontWeight.Bold) + .fontSize(24) + .onChange((newValue: string) => { + category[1].setAlias(newValue); + }) + .flexShrink(1) - Text('category.location') - } - .height(32) + Text('category.location') + .flexShrink(0) + .constraintSize({ minWidth: 120 }) + } + .justifyContent(FlexAlign.Center) + .height(60) + .width('100%') - PhotosGridView({ category: category[1] }) + this.photosGridView(category[1]) + } + .width('100%') + .height('100%') } - .width('100%') - .height('100%') + .margin(10) + .tabBar(category[1].name) } - .margin(10) - .tabBar(category[1].name) }) } .vertical(true) diff --git a/entry/src/main/ets/statusbar/StatusBarAbility.ets b/entry/src/main/ets/statusbar/StatusBarAbility.ets index 613a2cf..777ef1c 100644 --- a/entry/src/main/ets/statusbar/StatusBarAbility.ets +++ b/entry/src/main/ets/statusbar/StatusBarAbility.ets @@ -2,7 +2,7 @@ import { UIExtensionContentSession, Want } from '@kit.AbilityKit'; import { StatusBarViewExtensionAbility } from '@kit.DeskTopExtensionKit'; import { Logger } from 'nextcloud'; -const TAG = 'StatusBarView'; +const TAG = 'StatusBar/Ability'; export default class StatusBarView extends StatusBarViewExtensionAbility { onCreate() { diff --git a/entry/src/main/ets/statusbar/StatusBarPage.ets b/entry/src/main/ets/statusbar/StatusBarPage.ets index 1677502..f73bea8 100644 --- a/entry/src/main/ets/statusbar/StatusBarPage.ets +++ b/entry/src/main/ets/statusbar/StatusBarPage.ets @@ -1,35 +1,23 @@ -import { - TransferTask, - Logger, -} from 'nextcloud'; -import { BusinessError } from '@kit.BasicServicesKit'; -import { commonEventManager } from '@kit.BasicServicesKit'; -import { - TRANSFER_MANAGER_COMMON_EVENT_NAME, - TransferManagerEvent, - TransferManagerEventCode, -} from 'nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC'; +import { Logger } from 'nextcloud'; +import { TransferJobStatus, TransferStatusReceiver } from './TransferStatusReceiver'; const TAG = 'StatusBar/Page'; @ComponentV2 struct TransferProgressItem { - @Require @Param transferTask: TransferTask; + @Require @Param task: TransferJobStatus; build() { Column({ space: 4 }) { - Text(this.transferTask.filename) + Text(this.task.fileName) .textOverflow({ overflow: TextOverflow.MARQUEE }) .textAlign(TextAlign.Start) .width('100%') - Progress({ - value: this.transferTask.transferredSize, - total: this.transferTask.totalSize, - }) + Progress({ value: this.task.transferredSize, total: this.task.totalSize }) .flexGrow(1) - Text(this.transferTask.progressPercentText) + Text(this.task.progressPercentText) } .backgroundColor($r('sys.color.ohos_id_color_hover')) .padding(8) @@ -41,60 +29,24 @@ struct TransferProgressItem { @Entry @ComponentV2 struct StatusBarPage { - @Local tasks: Map = new Map(); - private subscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; - private readonly subscribeInfo: commonEventManager.CommonEventSubscribeInfo = { - events: [TRANSFER_MANAGER_COMMON_EVENT_NAME] - }; + private statusReceiver: TransferStatusReceiver = new TransferStatusReceiver(); - private readonly subscribeCB = async (data: commonEventManager.CommonEventData) => { - console.info(`Succeeded in subscribing, data is ` + JSON.stringify(data)); - let event: TransferManagerEvent = JSON.parse(data.data!); - switch (event.code) { - case TransferManagerEventCode.TASK_CREATE: { - this.tasks.set(event.taskId, new TransferTask(event.task)); - break; - } - case TransferManagerEventCode.TASK_PROGRESS: { - let task = this.tasks.get(event.taskId)!; - task.transferredSize = event.transferredSize; - break; - } - case TransferManagerEventCode.TASK_COMPLETED: { - let task = this.tasks.get(event.taskId)!; - task.transferredSize = task.totalSize; - break; - } - case TransferManagerEventCode.TASK_FAILED: { - let task = this.tasks.get(event.taskId)!; - task.transferredSize = 0; - break; - } - } - } - - async aboutToAppear(): Promise { - try { - this.subscriber = await commonEventManager.createSubscriber(this.subscribeInfo); - Logger.Info(TAG, `succeeded in creating subscriber.`); - await commonEventManager.subscribeToEvent(this.subscriber, this.subscribeCB); - Logger.Info(TAG, `succeeded in subscribing`); - } catch (error) { - let err: BusinessError = error as BusinessError; - Logger.Error(TAG, `Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); - } + aboutToAppear(): void { + Logger.Info(TAG, `aboutToAppear`); } build() { Scroll() { Column({ space: 16 }) { - ForEach(Array.from(this.tasks).reverse(), (item: [number, TransferTask]) => { - TransferProgressItem({ - transferTask: item[1] + if (this.statusReceiver) { + ForEach(Array.from(this.statusReceiver.tasks).reverse(), (item: [number, TransferJobStatus]) => { + TransferProgressItem({ + task: item[1] + }) + }, (item: [number, TransferJobStatus], _index: number) => { + return `${item[0]}` }) - }, (item: [number, TransferTask], index: number) => { - return `${item[0]}` - }) + } } } .padding(16) diff --git a/entry/src/main/ets/statusbar/TransferStatusReceiver.ets b/entry/src/main/ets/statusbar/TransferStatusReceiver.ets new file mode 100644 index 0000000..50b12f8 --- /dev/null +++ b/entry/src/main/ets/statusbar/TransferStatusReceiver.ets @@ -0,0 +1,83 @@ +import { commonEventManager } from '@kit.BasicServicesKit'; +import { Logger } from 'nextcloud'; +import { + TransferJobBasicInfo, + TRANSFER_MANAGER_COMMON_EVENT_NAME, + TransferManagerEvent, + TransferManagerEventCode, + TransferType +} from 'nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC'; + +const TAG = 'StatusBar/TransferStatusReceiver'; + +@ObservedV2 +export class TransferJobStatus { + @Trace id: number; + @Trace type: TransferType; + @Trace fileName: string; + @Trace totalSize: number; + @Trace transferredSize: number; + + constructor(task: TransferJobBasicInfo) { + this.id = task.id; + this.type = task.type; + this.fileName = task.fileName; + this.totalSize = task.totalSize; + this.transferredSize = task.transferredSize; + } + + public get progressPercentText() { + let progress = this.transferredSize / this.totalSize; + return `${(progress * 100).toFixed(1)}%`; + } +} + +@ObservedV2 +export class TransferStatusReceiver { + @Trace tasks: Map = new Map(); + private subscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; + private readonly subscribeInfo: commonEventManager.CommonEventSubscribeInfo = { + events: [TRANSFER_MANAGER_COMMON_EVENT_NAME] + }; + private readonly subscribeCB = async (data: commonEventManager.CommonEventData) => { + console.info(`Succeeded in subscribing, data is ` + JSON.stringify(data)); + let event: TransferManagerEvent = JSON.parse(data.data!); + switch (event.code) { + case TransferManagerEventCode.TASK_CREATED: { + this.tasks.set(event.taskId, new TransferJobStatus(event.task)); + break; + } + case TransferManagerEventCode.TASK_PROGRESS: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = event.transferredSize; + break; + } + case TransferManagerEventCode.TASK_COMPLETED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = task.totalSize; + break; + } + case TransferManagerEventCode.TASK_FAILED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = 0; + break; + } + } + } + + constructor() { + this.doSubscribe(); + } + + private async doSubscribe() { + try { + this.subscriber = await commonEventManager.createSubscriber(this.subscribeInfo); + Logger.Info(TAG, `succeeded in creating subscriber.`); + await commonEventManager.subscribeToEvent(this.subscriber, this.subscribeCB); + Logger.Info(TAG, `succeeded in subscribing`); + } catch (error) { + let err: BusinessError = error as BusinessError; + Logger.Error(TAG, `Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); + } + } +} diff --git a/entry/src/main/ets/utils/DownloadQueue.ets b/entry/src/main/ets/utils/DownloadQueue.ets index c5a1bd4..2dfee39 100644 --- a/entry/src/main/ets/utils/DownloadQueue.ets +++ b/entry/src/main/ets/utils/DownloadQueue.ets @@ -1,4 +1,4 @@ -import { Account, DirectoryEntry, ListFiles, Logger } from "nextcloud"; +import { Account, DirectoryEntry, ListFiles, Logger } from 'nextcloud'; const DOWNLOAD_DEPTH_FIRST: boolean = true; const TAG = 'Utils/DownloadQueue'; @@ -20,7 +20,6 @@ export class DownloadItem { } } - export class DownloadQueue { queue: DownloadItem[] = []; @@ -55,7 +54,6 @@ export class DownloadQueue { Logger.Debug(TAG, `downloading dir: ${next.relativePath}`); let items = await ListFiles(next.account, next.relativePath); let downloadItems = items.map((item: DirectoryEntry): DownloadItem => { - Logger.Info(TAG, `${item.name}`); return next!.makeChildItem(item.name, item.isDirectory); }); diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets index b92cbdc..843ccff 100644 --- a/entry/src/main/ets/utils/NotificationManager.ets +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -1,52 +1,24 @@ -import { wantAgent } from '@kit.AbilityKit'; -import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'; import { Logger } from 'nextcloud'; -import { BUNDLE_NAME } from 'BuildProfile'; -import { BusinessError } from '@ohos.base'; import { notificationManager } from '@kit.NotificationKit'; const TAG = "Util-NotificationManager"; -class NotificationImpl { - private readonly WANT_AGENT_INFO: wantAgent.WantAgentInfo = { - wants: [{ bundleName: BUNDLE_NAME, abilityName: "EntryAbility" }], - actionType: wantAgent.OperationType.START_ABILITY, - requestCode: 0, - wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] - }; - +class Notification { private notificationId: Optional = undefined; - private idleTimer: number = 0; - - public async startNotification(context: Context) { - Logger.Info(TAG, `startNotification`); - try { - let wantAgentObj = await wantAgent.getWantAgent(this.WANT_AGENT_INFO); - let req = new backgroundTaskManager.ContinuousTaskRequest(); - req.backgroundTaskModes = [backgroundTaskManager.BackgroundTaskMode.MODE_TASK_KEEPING]; - req.backgroundTaskSubmodes = [backgroundTaskManager.BackgroundTaskSubmode.SUBMODE_NORMAL_NOTIFICATION]; - req.wantAgent = wantAgentObj; - - let res = await backgroundTaskManager.startBackgroundRunning(context, req); - Logger.Info(TAG, `${JSON.stringify(res)}`); - - this.notificationId = 111; - await this.startIdleLoop(); - Logger.Info(TAG, `startNotification: succeeded: ${this.notificationId}`); - } catch (error) { - let e = error as BusinessError; - Logger.Error(TAG, `startNotification: failed. code is ${e.code} message is ${e.message}`); - } - } - - public async beginIdle() { - Logger.Info(TAG, `startIdle: enter`); + public async cancelNotification(timeout: number = 0) { if (this.notificationId === undefined) { - Logger.Warn(TAG, `startIdle: notificationId is undefined, return directly`); + Logger.Warn(TAG, "cancelNotification: notificationId is undefined, return directly"); return; } - await this.startIdleLoop(); + + if (timeout) { + setTimeout(async () => { + await this.doCancelNotification(); + }, timeout); + } else { + await this.doCancelNotification(); + } } public async publishNotification(title: string, content: string, percent: number) { @@ -55,20 +27,16 @@ class NotificationImpl { return; } - clearInterval(this.idleTimer); await this.doPublishNotification(title, content, percent); } - private async startIdleLoop(): Promise { - let doPublishIdleNotificaton = async () => { - await this.doPublishNotification('Nexcloud is Idle', `Since ${new Date().toLocaleTimeString()}`, 1); - }; - - await doPublishIdleNotificaton(); - clearInterval(this.idleTimer); - this.idleTimer = setInterval(async () => { - await doPublishIdleNotificaton(); - }, 90 * 1000); ///< 90s + private async doCancelNotification() { + try { + await notificationManager.cancel(this.notificationId); + Logger.Info(TAG, `doCancelNotification: cancelled notification id ${this.notificationId}`); + } catch (err) { + Logger.Error(TAG, `doCancelNotification: cancel fail: ${JSON.stringify(err)}`); + } } private async doPublishNotification(title: string, subtitle: string, progress: number) { @@ -102,4 +70,10 @@ class NotificationImpl { } } -export const NotificationManager = new NotificationImpl(); +export namespace NotificationManager { + export async function publishNotification(title: string, content: string, percent: number): Promise { + let notification = new Notification(); + notification.publishNotification(title, content, percent); + return notification; + } +} diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index 410a2f4..fbe39d2 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -46,6 +46,10 @@ export class Account { return `https://${this.dbAccount.hostname}/remote.php/dav/files/${this.dbAccount.username}/`; } + public chunkedUploadBaseUrl(): string { + return `https://${this.dbAccount.hostname}/remote.php/dav/uploads/${this.dbAccount.username}/`; + } + public getPreviewUrl(fileId: string, x: number = 64, y: number = 64): string { return this.baseUrlWithCredentials + `/core/preview?fileId=${fileId}&x=${x}&y=${y}&mimeFallback=true`; } diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index 5ddb942..643fa0c 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -96,4 +96,14 @@ export namespace NCommon { Logger.Info(TAG, "Common initialised with User-Agent: " + UserAgent()); Preference = new NPreference(context); } + + export function RandomString(length: number): string { + const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'; + let result = ''; + const charactersLength = CHARACTERS.length; + for (let i = 0; i < length; i++) { + result += CHARACTERS.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; + } } diff --git a/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets new file mode 100644 index 0000000..b814bd0 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets @@ -0,0 +1,11 @@ +import { DBLocalSyncFolder, LocalFolderType } from "../DbTypes/DBLocalFolder"; +import { LocalFolder } from "./LocalFolder"; + +export class LocalAlbumFolder extends LocalFolder { + constructor(dbLocalFolder: DBLocalSyncFolder) { + super(dbLocalFolder); + if (dbLocalFolder.folderType !== LocalFolderType.PhotoAlbum) { + throw Error("LocalAlbumFolder must be of PhotoAlbum type"); + } + } +} diff --git a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets index 5a8c9f7..e538ac3 100644 --- a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets +++ b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets @@ -1,4 +1,6 @@ -import { DBLocalSyncFolder, LocalFolderType } from "../DbTypes/DBLocalFolder"; +import { DBLocalSyncFolder, LocalFolderType } from '../DbTypes/DBLocalFolder'; + +export interface LocalFolderItem {} export class LocalFolder { private dbLocalFolder_: DBLocalSyncFolder; @@ -36,4 +38,20 @@ export class LocalFolder { public get alias(): string { return this.dbLocalFolder_.alias; } + + public set alias(newAlias: string) { + this.dbLocalFolder_.alias = newAlias; + } + + public getFullPath(relativePath: string): string { + if (this.dbLocalFolder_.folderType === LocalFolderType.Regular) { + if (this.localPath.endsWith('/')) { + return this.localPath + relativePath; + } else { + return this.localPath + '/' + relativePath; + } + } else { + return ""; + } + } } diff --git a/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets index e59f61d..4d38109 100644 --- a/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets +++ b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets @@ -3,16 +3,34 @@ import { DBRemoteFolder } from '../DbTypes/DBRemoteFolder'; export class RemoteFolder { private dbRemoteFolder_: DBRemoteFolder; + private account_: Account; - constructor(dbRemoteFolder: DBRemoteFolder) { + constructor(account: Account, dbRemoteFolder: DBRemoteFolder) { this.dbRemoteFolder_ = dbRemoteFolder; + this.account_ = account; } public get id(): number { return this.dbRemoteFolder_.id; } + public get accountId(): number { + return this.dbRemoteFolder_.accountId; + } + + public get account(): Account { + return this.account_; + } + public get remotePath(): string { return this.dbRemoteFolder_.remotePath; } + + public getFullPath(relativePath: string): string { + let basePath = this.remotePath; + if (!basePath.endsWith('/')) { + basePath += '/'; + } + return basePath + relativePath; + } } diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index a3fad66..2989b66 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -2,6 +2,12 @@ * Nextcloud module entry point */ +export { SyncConfiguration } from './Database/Types/SyncConfiguration'; + +export { TransferJob, TransferJobData } from './SyncEngine/Jobs/TransferJob'; + +export { UploadStatus } from './SyncEngine/PhotoItem'; + export { NCommon } from './Common'; export { ListFiles } from './webdav/Files'; @@ -16,19 +22,17 @@ export { BasicDataSource } from './Utils/BasicDatasource'; export { FilesDataSource } from './Utils/FilesDataSource'; -export { SyncConfiguration } from './Database/Types/SyncConfiguration'; +export { JobManager, JobManagerImpl } from './SyncEngine/TransferManager'; export { SyncFolderManager } from './SyncEngine/SyncFolderManager'; -export { - TransferManager, - TransferManagerImpl, - TransferTask, -} from './SyncEngine/TransferManager'; - export { PhotoAssetsManager } from './SyncEngine/PhotoAssetsManager'; -export { CreatePath, UploadFile, DownloadFile } from './webdav/Uploader'; +export { Category } from './SyncEngine/Category'; + +export { PhotoItem } from './SyncEngine/PhotoItem'; + +export { CreatePath } from './webdav/TransferUtils'; export { Logger } from './Utils/Logger'; diff --git a/nextcloud/src/main/ets/Jobs/BaseJob.ets b/nextcloud/src/main/ets/Jobs/BaseJob.ets deleted file mode 100644 index 12c8d71..0000000 --- a/nextcloud/src/main/ets/Jobs/BaseJob.ets +++ /dev/null @@ -1,15 +0,0 @@ -import { Logger } from "../Index"; - -export class BaseJob { - public readonly jobName: string; - protected readonly TAG: string; - - constructor(name: string) { - this.jobName = name; - this.TAG = `Jobs/${this.jobName}`; - } - - async doWork(): Promise { - Logger.Info(this.TAG, `doWork unimplemented`); - } -} diff --git a/nextcloud/src/main/ets/SyncEngine/Category.ets b/nextcloud/src/main/ets/SyncEngine/Category.ets index 4d505db..9fb87a1 100644 --- a/nextcloud/src/main/ets/SyncEngine/Category.ets +++ b/nextcloud/src/main/ets/SyncEngine/Category.ets @@ -1,14 +1,9 @@ -import { Logger } from '../Utils/Logger'; import { ImageListDataSource } from './ImageListDataSource'; -import { - PhotoItem, - UploadResult -} from './PhotoItem'; -import { promptAction } from '@kit.ArkUI'; -import { NCommon } from '../Common'; +import { PhotoItem } from './PhotoItem'; import { LocalFolder } from '../Database/Types/LocalFolder'; import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; import { PhotoAssetsManager } from './PhotoAssetsManager'; +import { Logger } from '../Utils/Logger'; const TAG = 'models/Category'; @@ -23,15 +18,12 @@ export enum AddPosition { Prepend } -const IMAGE_LIST_MAX_ITEMS = 50; +const BATCH_SIZE = 150; -@Observed +@ObservedV2 export class Category { - private localFolder: LocalFolder; - /** - * Items that are currently visible on the UI (not all assets are listed for performance reason) - */ visibleItems: ImageListDataSource = new ImageListDataSource(); + private localFolder: LocalFolder; constructor(localFolder: LocalFolder) { if (localFolder.folderType !== LocalFolderType.PhotoAlbum) { @@ -45,8 +37,13 @@ export class Category { return this.localFolder.alias; } + public setAlias(newAlias: string) { + this.localFolder.alias = newAlias; + } + tryAddAsset(asset: PhotoItem, position: AddPosition): AddAssetResult { if (!this.matchesAsset(asset)) { + Logger.Error(TAG, `tryAddAsset: asset albumId ${asset.ownerAlbumId} doesn't match category albumId ${this.localFolder.albumId}`); return AddAssetResult.Unmatched; } @@ -59,39 +56,21 @@ export class Category { this.visibleItems.prependAsset(asset); return AddAssetResult.Added; } + default: { + Logger.Error(TAG, `tryAddAsset: unknown position ${position}`); + return AddAssetResult.Unmatched; + } } } async loadNextBatch(): Promise { const offset = this.visibleItems.totalCount(); - let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, 50); + let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, BATCH_SIZE); for (let item of items) { this.visibleItems.addAsset(item); } } - getAssets(start: number, length: number): PhotoItem[] { - let result: PhotoItem[] = []; - // Get from items if it's completely within range - if (start + length <= this.visibleItems.totalCount()) { - result = this.visibleItems.getDataRange(start, length); - } else if (start >= this.visibleItems.totalCount()) { - // Get from backedItems if it's completely within backedItems - let backedStart = start - this.visibleItems.totalCount(); - // for (let i = backedStart; i < Math.min(backedStart + length, this.invisibleBackedItems.size); i++) { - // result.push(this.invisibleBackedItems[i]); - // } - } else { - // Get from both items and backedItems - let itemsPartLength = this.visibleItems.totalCount() - start; - result = this.visibleItems.getDataRange(start, itemsPartLength); - let backedPartLength = length - itemsPartLength; - // for (let i = 0; i < Math.min(backedPartLength, this.invisibleBackedItems.size); i++) { - // result.push(this.invisibleBackedItems[i]); - // } - } - return result; - } // getTotalAvailableCount(): number { // return this.visibleItems.totalCount() + this.invisibleBackedItems.size; @@ -133,6 +112,6 @@ export class Category { // } private matchesAsset(asset: PhotoItem): boolean { - return asset.ownerAlbumId === this.localFolder.id; + return asset.ownerAlbumId === this.localFolder.albumId; } } diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets new file mode 100644 index 0000000..174e8bc --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets @@ -0,0 +1,64 @@ +import { Logger } from '../../Index'; + +export enum JobState { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + FAILED = 'failed' +} + +export interface JobData {} + +export class BaseJob { + public readonly jobName: string; + protected readonly TAG: string; + protected state: JobState = JobState.PENDING; + private stateListeners: Array<(state: string) => void> = []; + + constructor(name: string) { + this.jobName = name; + this.TAG = `Jobs/${this.jobName}`; + } + + public get jobState(): string { + return this.state; + } + + public addStateListener(listener: (state: string) => void): void { + this.stateListeners.push(listener); + } + + public removeStateListener(listener: (state: string) => void): void { + this.stateListeners = this.stateListeners.filter(l => l !== listener); + } + + public start(): Promise { + this.setState(JobState.IN_PROGRESS); + let promise = new Promise(async (resolve, reject) => { + try { + Logger.Info(this.TAG, `Job ${this.jobName} started.`); + await this.onWork(); + setTimeout(() => { + this.setState(JobState.COMPLETED); + Logger.Info(this.TAG, `Job ${this.jobName} completed.`); + resolve(); + }, 1000); + } catch (error) { + this.setState(JobState.FAILED); + Logger.Error(this.TAG, `Job ${this.jobName} failed with error: ${error}`); + reject(error); + } + }); + + return promise; + } + + protected async onWork(): Promise { + throw new Error(`onWork method not implemented for job ${this.jobName}`); + } + + private setState(newState: JobState) { + this.state = newState; + this.stateListeners.forEach(listener => listener(this.state)); + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets new file mode 100644 index 0000000..1889411 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets @@ -0,0 +1,28 @@ +import { TransferJob, TransferJobData } from './TransferJob'; +import { Logger } from '../../Utils/Logger'; +import { rcp } from '@kit.RemoteCommunicationKit'; +import { TransferType } from '../TransferManagerEventIPC'; + +export interface DownloadJobData extends TransferJobData {} + +@ObservedV2 +export class DownloadTransferJob extends TransferJob { + constructor(id: number, data: DownloadJobData) { + super(id, TransferType.Download, data); + } + + protected async onTransferWork(session: rcp.Session) { + Logger.Info(this.TAG, `onTransferWork enter`); + const localFullPath = this.localFolder.getFullPath(this.relativePath); + const remoteFullPath = this.remoteFolder.getFullPath(this.relativePath); + + let downloadToFileOptions: rcp.DownloadToFile = { + kind: "file", + file: localFullPath, + }; + + + await session.downloadToFile(this.remoteFolder.account.remoteBaseDirUrl() + remoteFullPath, downloadToFileOptions); + + } +} diff --git a/nextcloud/src/main/ets/Jobs/Index.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/Index.ets similarity index 100% rename from nextcloud/src/main/ets/Jobs/Index.ets rename to nextcloud/src/main/ets/SyncEngine/Jobs/Index.ets diff --git a/nextcloud/src/main/ets/Jobs/MkColJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/MkColJob.ets similarity index 100% rename from nextcloud/src/main/ets/Jobs/MkColJob.ets rename to nextcloud/src/main/ets/SyncEngine/Jobs/MkColJob.ets diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets new file mode 100644 index 0000000..a53bf7c --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets @@ -0,0 +1,144 @@ +import { LocalFolder } from '../../Database/Types/LocalFolder'; +import { RemoteFolder } from '../../Database/Types/RemoteFolder'; +import { Logger } from '../../Utils/Logger'; +import { CreateSession } from '../../webdav/TransferUtils'; +import { + publishTransferEvent, TransferManagerEventCode, TransferType +} from '../TransferManagerEventIPC'; +import { BaseJob, JobData } from './BaseJob'; +import { rcp } from '@kit.RemoteCommunicationKit'; + +export interface TransferJobData extends JobData { + fileName: string; + + /** + * The local folder involved in the transfer + */ + localFolder: LocalFolder; + + /** + * The remote folder involved in the transfer + */ + remoteFolder: RemoteFolder; + + /** + * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: + * localPath = localFolder.path + relativePath + * remotePath = remoteFolder.path + relativePath + */ + relativePath: string; +} + +@ObservedV2 +export class TransferJob extends BaseJob { + readonly id: number; + @Trace public readonly type: TransferType; + @Trace public readonly filename: string; + @Trace public totalSize: number = 1; + @Trace public transferredSize: number = 0; + @Trace public localFolder: LocalFolder; + @Trace public remoteFolder: RemoteFolder; + @Trace public relativePath: string; + @Trace protected historicalBatchesTransferredSize: number = 0; + private readonly sessionExtraHeaders: Map = new Map(); + + constructor(id: number, type: TransferType, data: TransferJobData, extraHeaders?: Map) { + super(`TransferJob/${TransferType[type]}/${data.fileName}`); + this.id = id; + this.type = type; + this.filename = data.fileName; + this.localFolder = data.localFolder; + this.remoteFolder = data.remoteFolder; + this.relativePath = data.relativePath; + if (extraHeaders) { + this.sessionExtraHeaders = extraHeaders; + } + } + + public get progressPercentText() { + let progress = this.transferredSize / this.totalSize; + return `${(progress * 100).toFixed(1)}%`; + } + + protected async onWork() { + Logger.Info(this.TAG, `doWork enter`); + + const eventsHandler: rcp.HttpEventsHandler = { + onDownloadProgress: (totalBytes: number, transferredBytes: number) => { + this.onDownloadProgress(transferredBytes, totalBytes); + }, + onUploadProgress: (_totalBytes: number, transferredBytes: number) => { + this.onUploadProgress(transferredBytes); + } + }; + + let session = CreateSession(this.remoteFolder.account, this.sessionExtraHeaders, eventsHandler); + if (this.type === TransferType.Upload) { + // Upload job - total size is known beforehand, notify task created immediately + this.notifyTaskCreated(); + } + + const timerId = setTimeout(() => { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_PROGRESS, + taskId: this.id, + transferredSize: this.transferredSize + }) + }, 500); + + try { + await this.onTransferWork(session); + session.close(); + } catch (error) { + Logger.Error(this.TAG, `onTransferWork failed, error: ${JSON.stringify(error)}`); + session.close(); + throw error as Error; + } finally { + clearTimeout(timerId); + } + + this.transferredSize = this.totalSize; + publishTransferEvent({ + code: TransferManagerEventCode.TASK_COMPLETED, + taskId: this.id, + }) + } + + protected async onTransferWork(_session: rcp.Session) { + throw Error("onTransferWork not implemented"); + } + + protected onDownloadProgress(transferredBytes: number, totalBytes: number) { + this.transferredSize = transferredBytes; + if (this.totalSize === 0 && totalBytes > 0) { + this.totalSize = totalBytes; + // First time we get total size, notify task created + this.notifyTaskCreated(); + } else { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_PROGRESS, + taskId: this.id, + transferredSize: this.transferredSize + }) + } + } + + protected onUploadProgress(transferredBytes: number) { + Logger.Info(this.TAG, `onUploadProgress: transferredBytes=${transferredBytes}`); + this.transferredSize = this.historicalBatchesTransferredSize + transferredBytes; + } + + private notifyTaskCreated() { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_CREATED, + taskId: this.id, + task: { + id: this.id, + type: this.type, + fileName: this.filename, + totalSize: this.totalSize, + transferredSize: this.transferredSize + }, + }); + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets new file mode 100644 index 0000000..c20f05d --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets @@ -0,0 +1,124 @@ +import { IsRemotePathExists } from '../../webdav/TransferUtils'; +import { TransferJob, TransferJobData } from './TransferJob'; +import { fileIo } from '@kit.CoreFileKit'; +import { Logger } from '../../Utils/Logger'; +import { rcp } from '@kit.RemoteCommunicationKit'; +import { TransferType } from '../TransferManagerEventIPC'; +import { NCommon } from '../../Common'; + +export interface UploadJobData extends TransferJobData { + /** + * Local item modified time in milliseconds since epoch + */ + mTime: number; + + /** + * Uri of the local item + */ + uri: string; +} + +@ObservedV2 +export class UploadTransferJob extends TransferJob { + private uploadStatusCode: number = 0; + private readonly uri: string; + private readonly file: fileIo.File; + private readonly fileStat: fileIo.Stat; + + constructor(id: number, data: UploadJobData) { + let extraHeaders = new Map(); + extraHeaders.set('X-OC-MTime', `${Math.round(data.mTime / 1000)}`); + super(id, TransferType.Upload, data, extraHeaders); + this.uri = data.uri; + try { + this.fileStat = fileIo.statSync(this.uri); + this.file = fileIo.openSync(this.uri); + this.totalSize = this.fileStat.size; + } catch (error) { + Logger.Error(this.TAG, `failed to create readonly file: ${JSON.stringify(error)}`); + throw error as Error; + } + } + + public get statusCode(): number { + return this.uploadStatusCode; + } + + protected async onTransferWork(session: rcp.Session) { + Logger.Info(this.TAG, `doWork enter`); + const account = this.remoteFolder.account; + const destPath = this.remoteFolder.getFullPath(this.relativePath); + const destFileFullPath = account.remoteBaseDirUrl() + destPath; + + let shouldReplace = true // NCommon.Preference.replaceExistingFiles; + if (!shouldReplace) { + if (await IsRemotePathExists(session, account, destPath)) { + Logger.Info(this.TAG, `file already exists: ${destPath}, not replacing as per settings`); + session.close(); + this.uploadStatusCode = 204; // No Content, file already exists + return; + } + } + + const tempDirectory = NCommon.RandomString(16); + const chunkedUploadRemoteDir = account.chunkedUploadBaseUrl() + tempDirectory + `/`; + session.configuration!.headers!['Destination'] = destFileFullPath; + + try { + let resp = await session.fetch(new rcp.Request(chunkedUploadRemoteDir, "MKCOL", {})); + Logger.Info(this.TAG, `Created chunked upload temp directory '${tempDirectory}' with response: ${resp.statusCode}`); + if (resp.statusCode !== 201) { + throw new Error(`Failed to create chunked upload temp directory, status code: ${resp.statusCode}`); + } + } catch (e) { + Logger.Error(this.TAG, `failed to create chunked upload temp directory '${tempDirectory}': ${e}`); + await session.delete(chunkedUploadRemoteDir); + await fileIo.close(this.file.fd); + throw e as Error; + } + + let remainingSize = this.fileStat.size; + session.configuration!.headers!['OC-Total-Length'] = `${this.fileStat.size}`; + try { + let fileCount = 0; + for (let uploadedSize = 0; uploadedSize < this.fileStat.size; ) { + let chunkSize = Math.min(remainingSize, DEFAULT_CHUNK_SIZE); + let arrayBuffer = new ArrayBuffer(chunkSize); + let readLen = await fileIo.read(this.file.fd, arrayBuffer, { + length: chunkSize, + offset: uploadedSize + }); + + let chunkPath = chunkedUploadRemoteDir + fileCount.toString().padStart(5, '0'); + Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for chunked upload to ${chunkPath}`); + + let resp = await session.put(chunkPath, arrayBuffer); + Logger.Info(this.TAG, `Chunk upload response for ${chunkPath}: ${resp.statusCode}`); + + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Chunk upload failed with status code: ${resp.statusCode}`); + } + + uploadedSize += readLen; + remainingSize -= readLen; + fileCount++; + this.historicalBatchesTransferredSize = uploadedSize; + } + // Finalize chunked upload + let moveRequest = new rcp.Request(chunkedUploadRemoteDir + '.file', "MOVE", {}); + let resp = await session.fetch(moveRequest); + Logger.Info(this.TAG, `Finalized chunked upload with response: ${resp.statusCode}`); + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Finalizing chunked upload failed with status code: ${resp.statusCode}`); + } + this.uploadStatusCode = resp.statusCode; + } catch (e) { + await session.delete(chunkedUploadRemoteDir); + Logger.Error(this.TAG, `chunked upload failed: ${e}`); + } finally { + await fileIo.close(this.file.fd); + } + } +} + +const DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024; // 20 MB diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets index 06bd073..b7bda7b 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -1,4 +1,3 @@ -import { Logger, NCommon } from '../Index'; import { AddPosition, Category } from './Category'; import { PhotoItem } from './PhotoItem'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; @@ -6,11 +5,11 @@ import { dataSharePredicates } from '@kit.ArkData'; import { LocalFolder } from '../Database/Types/LocalFolder'; import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; import { Nextcloud } from '../Main'; +import { NCommon } from '../Common'; +import { Logger } from '../Utils/Logger'; const TAG = 'Utils/PhotoAssetsManager'; -const OWNER_ALBUM_ID = 'owner_album_id'; - @ObservedV2 class PhotoAssetsManagerImpl { private static readonly FETCH_COLUMNS = [ @@ -28,53 +27,22 @@ class PhotoAssetsManagerImpl { photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, photoAccessHelper.PhotoKeys.DETAIL_TIME, photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, - OWNER_ALBUM_ID, // API22 + photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, ]; @Trace public categories: Map = new Map(); - private async getAllCategories(): Promise { - const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { - fetchColumns: [ - photoAccessHelper.PhotoKeys.URI, - photoAccessHelper.PhotoKeys.DATE_TAKEN, - photoAccessHelper.PhotoKeys.DISPLAY_NAME, - OWNER_ALBUM_ID, - ], - predicates: new dataSharePredicates.DataSharePredicates() - .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) - }; - - let albums: number[] = []; - try { - let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); - while (!result.isAfterLast()) { - const photoItem = await result.getNextObject(); - const albumId = photoItem.get(OWNER_ALBUM_ID) as number; - if (!albums.includes(albumId)) { - albums.push(albumId); - const displayName = photoItem.get(photoAccessHelper.PhotoKeys.DISPLAY_NAME); - Logger.Debug(TAG, `found albumId: ${albumId} via '${displayName}'`); - } - } - } catch (e) { - Logger.Info(TAG, `failed to get all photos: ${JSON.stringify(e)}`); - } - albums.sort((a, b) => a - b); - return albums; - } - public async loadNextBatchFromAlbumId(albumId: number, offset: number, limit: number): Promise { const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, predicates: new dataSharePredicates.DataSharePredicates() - .equalTo(OWNER_ALBUM_ID, albumId.toString()) + .equalTo(photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, albumId.toString()) .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) .limit(limit, offset) }; let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); let assets = await result.getAllObjects(); - Logger.Info(TAG, `loadNextBatchFromAlbumId:`, + Logger.Info(TAG, `loadNextBatchFromAlbumId:` + `albumId: ${albumId}`, `offset: ${offset}`, `limit: ${limit} -> ${assets.length} assets` @@ -132,13 +100,45 @@ class PhotoAssetsManagerImpl { } } + private async getAllCategories(): Promise { + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, + ], + predicates: new dataSharePredicates.DataSharePredicates() + .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) + }; + + let albums: number[] = []; + try { + let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + while (!result.isAfterLast()) { + const photoItem = await result.getNextObject(); + const albumId = photoItem.get(photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID) as number; + if (!albums.includes(albumId)) { + albums.push(albumId); + const displayName = photoItem.get(photoAccessHelper.PhotoKeys.DISPLAY_NAME); + Logger.Debug(TAG, `found albumId: ${albumId} via '${displayName}'`); + } + } + } catch (e) { + Logger.Info(TAG, `failed to get all photos: ${JSON.stringify(e)}`); + } + albums.sort((a, b) => a - b); + return albums; + } + private async doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { let ownerAlbumId = photoItem.ownerAlbumId; let category = this.categories.get(ownerAlbumId); if (category) { category.tryAddAsset(photoItem, addPosition); + } else { + Logger.Error(TAG, `add new category for ${ownerAlbumId} failed`); } - Logger.Error(TAG, `XXX: add new category for ${ownerAlbumId} failed`); } } diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets index 0b57550..c9d8600 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets @@ -1,5 +1,5 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { CreatePath, Logger, Nextcloud, UploadFile } from '../Index'; +import { CreatePath, JobManager, Logger, Nextcloud, SyncConfiguration } from '../Index'; import { promptAction } from '@kit.ArkUI'; import { image } from '@kit.ImageKit'; import { FormatTime } from '../Utils/CommonUtils'; @@ -21,9 +21,9 @@ export enum UploadResult { SUCCEEDED, } -@Observed +@ObservedV2 export class PhotoItem { - uploadStatus: UploadStatus = UploadStatus.None; + @Trace uploadStatus: UploadStatus = UploadStatus.None; private innerAsset: PHelper.PhotoAsset; constructor(asset: PHelper.PhotoAsset) { @@ -70,13 +70,13 @@ export class PhotoItem { return await this.innerAsset.getThumbnail(size); } - async upload(targetPath: string): Promise { - const activeAccountId: number = 0; - const account = Nextcloud.getAccountById(activeAccountId); + async upload(basePath: string): Promise { + const configuration = Nextcloud.getSyncFolderManager().allSyncConfigurations.values(); + let realConfiguration = configuration.next().value as SyncConfiguration; this.uploadStatus = UploadStatus.Uploading; - let path = targetPath + FormatTime(this.dateTaken, DEFAULT_FORMAT); - let mkdirResult = await CreatePath(account, path); + const path = basePath + FormatTime(this.dateTaken, DEFAULT_FORMAT); + let mkdirResult = await CreatePath(realConfiguration.remoteFolder.account, path); if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { // 405 is the status code for "already exists" Logger.Error(TAG, `Failed to create path: ${path}`); @@ -88,8 +88,18 @@ export class PhotoItem { let ret: UploadResult = UploadResult.FAILED; + let job = JobManager.createUploadJob({ + fileName: this.displayName, + mTime: this.dateTakenMs, + uri: this.uri, + localFolder: realConfiguration.localFolder, + remoteFolder: realConfiguration.remoteFolder, + relativePath: path + '/' + this.displayName, + }); + await job.start(); + let message = ""; - let uploadResult = await UploadFile(account, this.displayName, this.dateTakenMs, path, this.uri); + let uploadResult = job.statusCode; if (uploadResult !== 201 && uploadResult !== 204) { message = `Failed: ${this.displayName}: ${uploadResult}`; this.uploadStatus = UploadStatus.Failed; diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index a5403fa..9417e94 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -8,6 +8,7 @@ import { DBLocalSyncFolder, DBLocalSyncFolderTable, LocalFolderType } from '../D import { DBRemoteFolder, DBRemoteFolderTable } from '../Database/DbTypes/DBRemoteFolder'; import { Account } from '../Account/Account'; import { SyncConfiguration } from '../Database/Types/SyncConfiguration'; +import { Nextcloud } from '../Main'; const TAG = 'SyncEngine/SyncFolderManager'; @@ -27,6 +28,10 @@ export class SyncFolderManager { return this.localFolders_; } + public get allRemoteFolders(): Map { + return this.remoteFolders_; + } + public get allSyncConfigurations(): Map { return this.configurations_; } @@ -60,7 +65,7 @@ export class SyncFolderManager { public async addRemoteFolder(account: Account, remotePath: string, alias: string): Promise { Logger.Info(TAG, `addRemoteFolder: enter`); let dbRemoteFolder = new DBRemoteFolder(0, account.id, remotePath, alias); - let remoteFolder = new RemoteFolder(dbRemoteFolder); + let remoteFolder = new RemoteFolder(account, dbRemoteFolder); let insertedId = await this.dbManager_.createItem(dbRemoteFolder); dbRemoteFolder.id = insertedId!; this.remoteFolders_.set(dbRemoteFolder.id, remoteFolder); @@ -102,7 +107,8 @@ export class SyncFolderManager { let remoteFolders = await this.dbManager_.getItems(DBRemoteFolderTable); for (let remoteFolder of remoteFolders) { - this.remoteFolders_.set(remoteFolder.id, new RemoteFolder(remoteFolder)); + let account = Nextcloud.getAccountById(remoteFolder.accountId); + this.remoteFolders_.set(remoteFolder.id, new RemoteFolder(account, remoteFolder)); } let dbSyncConfiguration = await this.dbManager_.getItems(DBSyncConfigurationTable); @@ -162,7 +168,9 @@ export class SyncFolderManager { Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); return null; } - existing = new RemoteFolder(dbRemoteFolder); + + let account = Nextcloud.getAccountById(dbRemoteFolder.accountId); + existing = new RemoteFolder(account, dbRemoteFolder); this.remoteFolders_.set(id, existing); return existing; } diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManager.ets b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets index 844b98b..c4f03b0 100644 --- a/nextcloud/src/main/ets/SyncEngine/TransferManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets @@ -1,91 +1,42 @@ import { BasicDataSource } from '../Utils/BasicDatasource'; import { Logger } from '../Utils/Logger'; -import { - ITransferTask, - publishTransferEvent, - TransferManagerEventCode, - TransferType, -} from './TransferManagerEventIPC'; +import { BaseJob } from './Jobs/BaseJob'; +import { DownloadJobData, DownloadTransferJob } from './Jobs/DownloadTransferJob'; +import { TransferJob } from './Jobs/TransferJob'; +import { UploadJobData, UploadTransferJob } from './Jobs/UploadTransferJob'; const TAG = 'SyncEngine/TransferManager'; -@ObservedV2 -export class TransferTask { - readonly id: number; - @Trace public readonly type: TransferType; - @Trace public readonly filename: string; - @Trace public totalSize: number = 1; - @Trace public transferredSize: number = 0; - - constructor(task: ITransferTask) { - this.id = task.id; - this.type = task.type; - this.filename = task.filename; - this.totalSize = task.totalSize; - this.transferredSize = task.transferredSize; - } - - public get progressPercentText() { - let progress = this.transferredSize / this.totalSize; - return `${(progress * 100).toFixed(1)}%`; - } - - public updateProgress(transferredSize: number, totalSize: number): number { - if (this.totalSize != totalSize) { - this.totalSize = totalSize; - publishTransferEvent({ - code: TransferManagerEventCode.TASK_CREATE, - taskId: this.id, - task: { - id: this.id, - type: this.type, - filename: this.filename, - totalSize: this.totalSize, - transferredSize: this.transferredSize - }, - }) - } - - if (transferredSize !== transferredSize) { - Logger.Warn(TAG, `transferred size not changed`); - } else { - this.transferredSize = transferredSize; - - if (this.transferredSize !== this.totalSize) { - publishTransferEvent({ - code: TransferManagerEventCode.TASK_PROGRESS, - taskId: this.id, - transferredSize: this.transferredSize - }) - } else { - publishTransferEvent({ - code: TransferManagerEventCode.TASK_COMPLETED, - taskId: this.id, - }) - } - } - return this.transferredSize / this.totalSize; - } -} - -export class TransferManagerImpl { - private tasks_: BasicDataSource; +export class JobManagerImpl { + private tasks_: BasicDataSource; private idSeq_ = 0; constructor() { Logger.Info(TAG, `created TransferManager`); - this.tasks_ = new BasicDataSource(); + this.tasks_ = new BasicDataSource(); } public get tasks() { return this.tasks_; } - public addTransferTask(type: TransferType, fileName: string): TransferTask { - let id = this.idSeq_++; - let task = new TransferTask({ id, type, filename: fileName, transferredSize: 0, totalSize: 0 }); - return this.tasks_.prependItem(task); + public addDownloadJob(jobData: DownloadJobData): DownloadTransferJob { + let id = this.getNextId(); + let job = new DownloadTransferJob(id, jobData); + this.tasks_.prependItem(job); + return job; + } + + public createUploadJob(jobData: UploadJobData): UploadTransferJob { + const id = this.getNextId(); + const job = new UploadTransferJob(id, jobData); + this.tasks_.prependItem(job); + return job; + } + + private getNextId(): number { + return this.idSeq_++; } } -export let TransferManager = new TransferManagerImpl(); +export let JobManager = new JobManagerImpl(); diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets index ca4d4b1..ec0e80e 100644 --- a/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets +++ b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets @@ -1,32 +1,32 @@ -import { commonEventManager } from "@kit.BasicServicesKit"; -import { Logger } from "../Index"; +import { commonEventManager } from '@kit.BasicServicesKit'; +import { Logger } from '../Index'; const TAG = 'SyncEngine/TransferManager/IPC'; export enum TransferType { - DOWNLOAD, - UPLOAD, + Download, + Upload, } -export interface ITransferTask { +export interface TransferJobBasicInfo { id: number; type: TransferType; - filename: string; + fileName: string; totalSize: number; transferredSize: number; } export enum TransferManagerEventCode { - TASK_CREATE = 1, + TASK_CREATED = 1, TASK_PROGRESS = 2, TASK_COMPLETED = 3, TASK_FAILED = 4, } interface TransferManagerEvent_TaskCreate { - code: TransferManagerEventCode.TASK_CREATE; + code: TransferManagerEventCode.TASK_CREATED; taskId: number; - task: ITransferTask; + task: TransferJobBasicInfo; }; interface TransferManagerEvent_TaskProgress { @@ -47,9 +47,9 @@ interface TransferManagerEvent_TaskFailed { export type TransferManagerEvent = TransferManagerEvent_TaskCreate - | TransferManagerEvent_TaskProgress - | TransferManagerEvent_TaskCompleted - | TransferManagerEvent_TaskFailed; + | TransferManagerEvent_TaskProgress + | TransferManagerEvent_TaskCompleted + | TransferManagerEvent_TaskFailed; export const TRANSFER_MANAGER_COMMON_EVENT_NAME: string = 'Nextcloud.TransferManager.Events'; diff --git a/nextcloud/src/main/ets/Utils/Logger.ts b/nextcloud/src/main/ets/Utils/Logger.ts index a85f98a..bdbff8a 100644 --- a/nextcloud/src/main/ets/Utils/Logger.ts +++ b/nextcloud/src/main/ets/Utils/Logger.ts @@ -1,22 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later -import hilog from '@ohos.hilog' +import hilog from '@ohos.hilog'; const DOMAIN = 0xABAB; +const NEXTCLOUD_TAG = 'NextcloudApp'; export class Logger { static Debug(component: string, ...message: string[]) { - hilog.debug(DOMAIN, component, message.join(', ')) + hilog.debug(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) } static Info(component: string, ...message: string[]) { - hilog.info(DOMAIN, component, message.join(', ')) + hilog.info(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) } static Warn(component: string, ...message: string[]) { - hilog.warn(DOMAIN, component, message.join(', ')) + hilog.warn(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) } static Error(component: string, ...message: string[]) { - hilog.error(DOMAIN, component, message.join(', ')) + hilog.error(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) } } diff --git a/nextcloud/src/main/ets/webdav/TransferUtils.ets b/nextcloud/src/main/ets/webdav/TransferUtils.ets new file mode 100644 index 0000000..be1e8af --- /dev/null +++ b/nextcloud/src/main/ets/webdav/TransferUtils.ets @@ -0,0 +1,110 @@ +import { rcp } from '@kit.RemoteCommunicationKit'; +import { NCommon } from '../Common'; +import { Logger } from '../Utils/Logger'; +import { Account } from '../Account/Account'; + +const TAG = "webdav/Upload"; + +export function CreateSession( + account: Account, + extraHeaders?: Map, + eventsHandler?: rcp.HttpEventsHandler): rcp.Session { + let headers: rcp.RequestHeaders = {}; + + if (extraHeaders !== undefined) { + for (let item of extraHeaders) { + headers[item[0]] = item[1]; + } + } + + headers['authorization'] = account.makeAuthorizationHeader(); + headers['user-agent'] = NCommon.UserAgent(); + headers['accept'] = 'text/plains'; + + let sessionConfiguration: rcp.SessionConfiguration = { + headers: headers, + requestConfiguration: { + tracing: { + verbose: true, + infoToCollect: { + textual: true, + incomingHeader: true, + outgoingHeader: true, + incomingData: true, + outgoingData: true, + incomingSslData: true, + outgoingSslData: true + }, + collectTimeInfo: true, + httpEventsHandler: eventsHandler, + } + } + } + + try { + return rcp.createSession(sessionConfiguration); + } catch (error) { + Logger.Error(TAG, `failed to create session: ${JSON.stringify(error)}`); + throw error as Error; + } +} + +export async function IsRemotePathExists( + session: rcp.Session, + account: Account, + path: string): Promise { + let fullPath = account.remoteBaseDirUrl() + path; + Logger.Info(TAG, `IsRemotePathExists: checking if path exists: '${fullPath}'`); + + try { + let resp = await session.fetch(new rcp.Request(fullPath, "HEAD", {})); + if (resp.statusCode === 200 || resp.statusCode === 207) { + Logger.Info(TAG, "IsRemotePathExists: Path exists: " + fullPath); + return true; + } else if (resp.statusCode === 404) { + Logger.Info(TAG, "IsRemotePathExists: Path does not exist: " + fullPath); + return false; + } else { + Logger.Error(TAG, "IsRemotePathExists: Failed to check path existence: " + resp.statusCode); + return false; + } + } catch (e) { + Logger.Error(TAG, "IsRemotePathExists: failed: " + e); + return false; + } +} + +export async function CreatePath(account: Account, path: string): Promise { + let session = CreateSession(account); + if (await IsRemotePathExists(session, account, path)) { + Logger.Info(TAG, "Path already exists: " + path); + session.close(); + return 201; + } + + let segment = path.split("/"); + let respCode = -1; + for (let i = 0; i < segment.length; i++) { + let thisLevel = segment.slice(0, i + 1).join("/"); + if (!thisLevel) { + continue; + } + let fullPath = account.remoteBaseDirUrl() + thisLevel + "/"; + try { + let resp = await session.fetch(new rcp.Request(fullPath, "MKCOL", {})); + Logger.Info(TAG, `Create path '${thisLevel}', response: ${resp.statusCode}`); + respCode = resp.statusCode; + if (resp.statusCode !== 201 && resp.statusCode !== 405) { + Logger.Error(TAG, "Failed to create path: " + resp.statusCode); + session.close(); + return resp.statusCode; + } + } catch (e) { + Logger.Error(TAG, "Failed to create path: " + e); + session.close(); + return respCode; + } + } + session.close(); + return respCode; +} diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets deleted file mode 100644 index e2d86a5..0000000 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ /dev/null @@ -1,186 +0,0 @@ -import { rcp } from '@kit.RemoteCommunicationKit'; -import { NCommon } from '../Common'; -import fs from '@ohos.file.fs'; -import { Logger } from '../Utils/Logger'; -import { TransferManager } from '../SyncEngine/TransferManager'; -import { Account } from '../Account/Account'; -import { TransferType } from '../SyncEngine/TransferManagerEventIPC'; - -const TAG = "webdav/Upload"; - -function CreateSession( - account: Account, - extraHeaders?: Map, - eventsHandler?: rcp.HttpEventsHandler): rcp.Session { - let headers: rcp.RequestHeaders = {}; - - if (extraHeaders !== undefined) { - for (let item of extraHeaders) { - headers[item[0]] = item[1]; - } - } - - headers['authorization'] = account.makeAuthorizationHeader(); - headers['user-agent'] = NCommon.UserAgent(); - headers['accept'] = 'text/plains'; - - let sessionConfiguration: rcp.SessionConfiguration = { - headers: headers, - requestConfiguration: { - tracing: { - httpEventsHandler: eventsHandler, - } - } - } - - try { - return rcp.createSession(sessionConfiguration); - } catch (error) { - Logger.Error(TAG, `failed to create session: ${JSON.stringify(error)}`); - throw error as Error; - } -} - -export async function Exists( - session: rcp.Session, - account: Account, - path: string, - filename: string = ""): Promise { - let fullPath = account.remoteBaseDirUrl() + path + "/" + filename; - Logger.Info(TAG, "Checking if path exists: " + fullPath); - - try { - let resp = await session.fetch(new rcp.Request(fullPath, "HEAD", {})); - if (resp.statusCode === 200 || resp.statusCode === 207) { - Logger.Info(TAG, "Path exists: " + fullPath); - return true; - } else if (resp.statusCode === 404) { - Logger.Info(TAG, "Path does not exist: " + fullPath); - return false; - } else { - Logger.Error(TAG, "Failed to check path existence: " + resp.statusCode); - return false; - } - } catch (e) { - Logger.Error(TAG, "Error checking path existence: " + e); - return false; - } -} - -export async function CreatePath(account: Account, path: string): Promise { - let session = CreateSession(account); - if (await Exists(session, account, path)) { - Logger.Info(TAG, "Path already exists: " + path); - session.close(); - return 201; - } - - let segment = path.split("/"); - let respCode = -1; - for (let i = 0; i < segment.length; i++) { - let thisLevel = segment.slice(0, i + 1).join("/"); - if (!thisLevel) { - continue; - } - let fullPath = account.remoteBaseDirUrl() + thisLevel + "/"; - try { - let resp = await session.fetch(new rcp.Request(fullPath, "MKCOL", {})); - Logger.Info(TAG, `Create path '${thisLevel}', response: ${resp.statusCode}`); - respCode = resp.statusCode; - if (resp.statusCode !== 201 && resp.statusCode !== 405) { - Logger.Error(TAG, "Failed to create path: " + resp.statusCode); - session.close(); - return resp.statusCode; - } - } catch (e) { - Logger.Error(TAG, "Failed to create path: " + e); - session.close(); - return respCode; - } - } - session.close(); - return respCode; -} - -export async function DownloadFile(account: Account, relativePath: string, localPath: string): Promise { - let parts = relativePath.split('/'); - let task = TransferManager.addTransferTask(TransferType.DOWNLOAD, parts[parts.length - 1]); - - let transferredSize = 0; - let totalSize = 0; - - let timerId = setInterval(() => { - task.updateProgress(transferredSize, totalSize); - }, 500); - - let eventsHandler: rcp.HttpEventsHandler = { - onDownloadProgress: (totalBytes: number, transferredBytes: number) => { - transferredSize = transferredBytes; - totalSize = totalBytes; - } - }; - - let session = CreateSession(account, undefined, eventsHandler); - let downloadToFileOptions: rcp.DownloadToFile = { - kind: "file", - file: localPath, - }; - - await session.downloadToFile(account.remoteBaseDirUrl() + relativePath, downloadToFileOptions).then(() => { - clearInterval(timerId); - task.updateProgress(totalSize, totalSize); - }).catch((error: BusinessError) => { - Logger.Error(TAG, `Download failed: ${relativePath}, error: ${JSON.stringify(error)}`); - }); - clearInterval(timerId); - session.close(); -} - -export async function UploadFile( - account: Account, - filename: string, - mTime: number, - path: string, - uri: string): Promise { - let extraHeaders = new Map(); - extraHeaders.set('X-OC-MTime', `${Math.round(mTime / 1000)}`); - let session = CreateSession(account, extraHeaders); - - if (!NCommon.Preference.replaceExistingFiles) { - if (await Exists(session, account, path, filename)) { - Logger.Info(TAG, `file already exists: ${filename}, not replacing as per settings`); - session.close(); - return 204; // No Content, file already exists - } - } - - - let file: fs.File; - try { - file = await fs.open(uri); - Logger.Debug(TAG, "File opened"); - } catch (error) { - Logger.Error(TAG, `failed to create readonly file: ${JSON.stringify(error)}`); - throw error as Error; - } - - let fullpath = account.remoteBaseDirUrl() + path + "/" + filename; - Logger.Info(TAG, "Uploading file to " + fullpath); - - let ret = -1; - try { - let resp = await session.put(fullpath, new rcp.UploadFromFile(file)); - Logger.Info(TAG, "Upload response: " + resp.statusCode); - ret = resp.statusCode; - } catch (e) { - Logger.Error(TAG, "Failed to upload file: " + e); - } - - try { - await fs.close(file); - } catch (error) { - Logger.Warn(TAG, `failed to close the file: ${JSON.stringify(error)}`); - } - session.close(); - return ret; -} From 1aa839f5171264830e449751b2d41557d7350152 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sun, 25 Jan 2026 22:04:39 +0800 Subject: [PATCH 12/16] UploadTransferJob & UploadPage refactor --- entry/patch.json | 2 +- .../main/ets/entryability/EntryAbility.ets | 3 +- entry/src/main/ets/pages/FolderPage.ets | 16 +- entry/src/main/ets/pages/Index.ets | 64 ++-- entry/src/main/ets/pages/LoginPage.ets | 8 +- entry/src/main/ets/pages/PcMainPage.ets | 44 +-- entry/src/main/ets/pages/PreviewPage.ets | 35 +-- entry/src/main/ets/pages/SettingsPage.ets | 2 + entry/src/main/ets/pages/TransferProgress.ets | 47 +++ entry/src/main/ets/pages/UploadPage.ets | 276 +++++++++++++----- entry/src/main/ets/utils/PhotoMonitor.ets | 6 +- nextcloud/src/main/ets/Account/Account.ets | 29 +- nextcloud/src/main/ets/Database/DBManager.ets | 33 ++- .../main/ets/Database/DbTypes/DBAccount.ets | 23 +- .../ets/Database/DbTypes/DBLocalFolder.ets | 40 ++- .../ets/Database/DbTypes/DBRemoteFolder.ets | 5 + .../Database/DbTypes/DBSyncConfiguration.ets | 17 +- .../ets/Database/Types/LocalAlbumFolder.ets | 4 +- .../main/ets/Database/Types/LocalFolder.ets | 10 +- .../main/ets/Database/Types/RemoteFolder.ets | 4 + nextcloud/src/main/ets/Main.ets | 10 +- .../src/main/ets/SyncEngine/Category.ets | 7 +- .../src/main/ets/SyncEngine/Jobs/BaseJob.ets | 24 +- .../SyncEngine/Jobs/DownloadTransferJob.ets | 20 +- .../main/ets/SyncEngine/Jobs/TransferJob.ets | 18 -- .../ets/SyncEngine/Jobs/UploadTransferJob.ets | 36 ++- .../ets/SyncEngine/PhotoAssetsManager.ets | 17 +- .../src/main/ets/SyncEngine/PhotoItem.ets | 97 +++--- .../main/ets/SyncEngine/SyncFolderManager.ets | 63 ++-- nextcloud/src/main/module.json5 | 4 + 30 files changed, 618 insertions(+), 346 deletions(-) create mode 100644 entry/src/main/ets/pages/TransferProgress.ets diff --git a/entry/patch.json b/entry/patch.json index 5b8c160..a17bbd0 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2000984, + "patchVersionCode": 2001271, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index ee8d39e..fc39568 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,6 +1,6 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, wantAgent } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { AsyncSleep, InitialiseNextcloud, Logger, NCommon } from 'nextcloud'; +import { AsyncSleep, InitialiseNextcloud, Logger, NCommon, Nextcloud, PhotoAssetsManager } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; import { NotificationManager } from '../utils/NotificationManager'; @@ -45,6 +45,7 @@ export default class EntryAbility extends UIAbility { let page = this.isPc ? 'pages/PcMainPage' : 'pages/Index'; await this.startBackgroundRunning(); NCommon.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); + PhotoAssetsManager.initialize(Nextcloud.getSyncFolderManager().allLocalFolders); await windowStage.loadContent(page); if (this.isPc) { this.createTrayIcon(); diff --git a/entry/src/main/ets/pages/FolderPage.ets b/entry/src/main/ets/pages/FolderPage.ets index b5e7332..cff6539 100644 --- a/entry/src/main/ets/pages/FolderPage.ets +++ b/entry/src/main/ets/pages/FolderPage.ets @@ -1,20 +1,18 @@ -import { Account, DirectoryEntry, FilesDataSource, Logger, NCommon } from 'nextcloud'; +import { Account, DirectoryEntry, FilesDataSource, Logger } from 'nextcloud'; import { Path } from '../utils/Path'; import { PREVIEW_PAGE_NAME, PreviewPageProps } from './PreviewPage'; import { LengthMetrics } from '@kit.ArkUI'; import { FormatBytes } from '../utils/Utils'; -import { Constants, FolderViewMode } from '../utils/Common'; const TAG = "Page-FolderPage"; export let FOLDER_PAGE_NAME: string = "FolderPage"; -@Component +@ComponentV2 export struct FolderPage { - @ObjectLink account: Account; - @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; - @Consume('FolderList.PathStack') pageInfos: NavPathStack; - @Prop currentPath: Path; + @Require @Param account: Account; + @Require @Param currentPath: Path; + @Consumer('FolderList.PathStack') pageInfos: NavPathStack = new NavPathStack(); static formatSubtitle(mime: string, size: string) { let mimeParts = mime.split('/'); @@ -134,7 +132,7 @@ export struct FolderPage { .fontStyle(FontStyle.Italic) } .alignItems(HorizontalAlign.Start) - .width(this.viewMode == 1 ? '75%' : '70%') + .width('70%') } } .backgroundColor(Color.Transparent) @@ -149,7 +147,7 @@ export struct FolderPage { }) } .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) }) - .lanes(this.viewMode) + .lanes(2) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) .width('auto') .height('auto') diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 04a8163..6b18c7f 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1,30 +1,30 @@ -import { Account, Logger, NCommon, Nextcloud } from 'nextcloud'; +import { Account, Logger, Nextcloud } from 'nextcloud'; import { App, promptAction } from '@kit.ArkUI'; import { Path } from '../utils/Path'; import { UploadPage } from './UploadPage'; import { LoginPage } from './LoginPage'; import { SettingsPage } from './SettingsPage'; import { FolderPage } from './FolderPage'; -import { Constants, FolderViewMode } from '../utils/Common'; import { PermissionManager } from '../utils/PermissionManager'; import { PhotoAssetsManager } from 'nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager'; import { PhotoMonitor } from '../utils/PhotoMonitor'; import { ShowToast } from '../utils/Utils'; import { Permissions } from '@kit.AbilityKit'; +import { TransferProgress } from './TransferProgress'; const TAG = "Page-Index"; const EXIT_TOAST_DURATION: number = 2000; @Entry -@Component +@ComponentV2 struct Index { - @State currentIndex: number = 0; - @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; - @Provide('FolderList.PathStack') pathStack: NavPathStack = new NavPathStack(); - @StorageLink(Constants.PHONE_ACTIVE_ACCOUNT_ID) private readonly activeAccountId: number = 0; - @State private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); - @State private loginPageDialogId: number = 0; + @Local currentIndex: number = 0; + // @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; + @Provider('FolderList.PathStack') pathStack: NavPathStack = new NavPathStack(); + private readonly activeAccountId: number = 0; + @Local private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); + @Local private loginPageDialogId: number = 0; private backedOnce: boolean = false; onBackPress(): boolean { @@ -59,7 +59,6 @@ struct Index { ]; let result = await PermissionManager.RequestPermission(context, PERMISSIONS); Logger.Info(TAG, `Request permission result: ${result}`); - PhotoAssetsManager.initialize(Nextcloud.getSyncFolderManager().allLocalFolders); PhotoMonitor.beginMonitorPhotoChange(); } @@ -93,6 +92,17 @@ struct Index { build() { Tabs({ barPosition: BarPosition.End }) { + + TabContent() { + UploadPage({ account: this.account }) + .width('100%') + .height('100%') + } + .tabBar(SubTabBarStyle.of($r('app.string.tab_upload')) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) + TabContent() { Flex({ direction: FlexDirection.Column }) { Navigation(this.pathStack) { @@ -100,36 +110,32 @@ struct Index { } .navBarWidth(0) .mode(NavigationMode.Stack) - - Row({ space: 10 }) { - Button(FolderViewMode[this.viewMode]) - .onClick(() => { - this.viewMode = (this.viewMode % 3) + 1; - }) - .width(100) - } - .width('100%') - .alignItems(VerticalAlign.Center) - .justifyContent(FlexAlign.Center) - .height(60) - .margin({ top: 10, bottom: 10 }) } } - .tabBar($r("app.string.tab_files")) + .tabBar(SubTabBarStyle.of($r("app.string.tab_files")) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) TabContent() { - UploadPage() + TransferProgress() .width('100%') .height('100%') } - .tabBar($r('app.string.tab_upload')) + .tabBar(SubTabBarStyle.of('Transfer') + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) TabContent() { SettingsPage({ currentAccount: this.account }) .width('100%') .height('100%') } - .tabBar($r('app.string.tab_settings')) + .tabBar(SubTabBarStyle.of($r('app.string.tab_settings')) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) } .onChange((index: number) => { this.currentIndex = index; @@ -137,6 +143,10 @@ struct Index { .width('100%') .height('100%') .scrollable(false) + .linearGradient({ + angle: 45, + colors: [[0x0082C9, 0], [0x1CAFFF, 1]] + }) } @Builder diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index f7c47a4..59a8771 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -5,13 +5,13 @@ import { ShowAlertDialog, ShowToast } from '../utils/Utils'; const TAG = 'Pages/LoginPage'; -@Component +@ComponentV2 export struct LoginPage { - @Link public dialogId: number; + @Require @Param public dialogId: number; private webController: webview.WebviewController = new webview.WebviewController(); private loginFlow = new LoginFlow(); - @State private hostname: string = ''; - @State private loginStarted: boolean = false; + @Local private hostname: string = ''; + @Local private loginStarted: boolean = false; async doLogin(): Promise { await webview.WebCookieManager.clearAllCookies(); diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets index 607d1a8..7ca8d33 100644 --- a/entry/src/main/ets/pages/PcMainPage.ets +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -5,7 +5,6 @@ import { ListFiles, Logger, Nextcloud, - PhotoAssetsManager, SyncConfiguration, SyncFolderManager, TransferJob @@ -21,32 +20,11 @@ import { UploadPage } from './UploadPage'; import { emitter } from '@kit.BasicServicesKit'; import { Constants } from '../utils/Common'; import { PhotoMonitor } from '../utils/PhotoMonitor'; +import { TransferProgress } from './TransferProgress'; const TAG = 'PcAbility' const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; -@ComponentV2 -struct TransferProgressItem { - @Require @Param job: TransferJob; - - build() { - Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center, space: { main: LengthMetrics.vp(8) } }) { - Text(this.job.filename) - .width(180) - .textOverflow({ overflow: TextOverflow.MARQUEE }) - .textAlign(TextAlign.End) - - Progress({ - value: this.job.transferredSize, - total: this.job.totalSize, - }) - - Text(this.job.progressPercentText) - .width(100) - } - } -} - @ComponentV2 struct SyncConfigurationRow { @Require @Param readonly syncFolderManager: SyncFolderManager; @@ -194,19 +172,9 @@ struct PCAbility { Column({ space: 8 }) { Text('download progress') - List() { - LazyForEach(JobManager.tasks, (item: TransferJob) => { - ListItem() { - TransferProgressItem({ - job: item - }) - } - }, (item: TransferJob, _index: number) => { - return `${item.id}` - }) - } - .width('100%') - .height(250) + TransferProgress() + .width('100%') + .height(250) } Column({ space: 8 }) { @@ -242,8 +210,6 @@ struct PCAbility { 'ohos.permission.MEDIA_LOCATION', ]); - PhotoAssetsManager.initialize(this.syncFolderManager.allLocalFolders); - PhotoMonitor.beginMonitorPhotoChange(); for (let conf of this.syncFolderManager.allSyncConfigurations.values()) { Logger.Info(TAG, `conf: ${conf.id} - ${conf.localFolder.localPath} <=> ${conf.remoteFolder.remotePath}`); CloudSyncApi.connect(conf.localFolder.localPath); @@ -285,7 +251,7 @@ struct PCAbility { .tabBar('Paths') TabContent() { - UploadPage() + UploadPage({ account: this.account }) .width('100%') .height('100%') } diff --git a/entry/src/main/ets/pages/PreviewPage.ets b/entry/src/main/ets/pages/PreviewPage.ets index 88cfb0d..0f8fe12 100644 --- a/entry/src/main/ets/pages/PreviewPage.ets +++ b/entry/src/main/ets/pages/PreviewPage.ets @@ -6,28 +6,29 @@ const TAG: string = "Page-PreviewPage"; export let PREVIEW_PAGE_NAME: string = "PreviewPage"; -@Component +@ComponentV2 struct PreviewPage { - @ObjectLink account: Account; - @Prop entry: DirectoryEntry; - @Prop fullPath: Path = new Path(); - @State private previewLoaded: boolean = false; - @State private previewLink: string = this.account.getPreviewUrl(this.entry.fileId, 100, 100); - @State @Watch('onPreviewLevelChanged') private previewLevel: number = 0; - @State private failedToLoad: boolean = false; - @State private failedReason: string = ""; - @State private title: string = ""; + @Require @Param account: Account; + @Require @Param entry: DirectoryEntry; + @Require @Param fullPath: Path = new Path(); + @Local private previewLoaded: boolean = false; + @Local private previewLink: string = this.account.getPreviewUrl(this.entry.fileId, 100, 100); + @Local private previewLevel: number = 0; + @Local private failedToLoad: boolean = false; + @Local private failedReason: string = ""; + @Local private title: string = ""; // Zoom and Pan states - @State private imageScale: number = 1; - @State private offsetX: number = 0; - @State private offsetY: number = 0; - @State private eventOffsetX: number = 0; - @State private eventOffsetY: number = 0; - @State private previousScale: number = 1; + @Local private imageScale: number = 1; + @Local private offsetX: number = 0; + @Local private offsetY: number = 0; + @Local private eventOffsetX: number = 0; + @Local private eventOffsetY: number = 0; + @Local private previousScale: number = 1; // private startPinchX: number = 0; private startPinchY: number = 0; + @Monitor('previewLevel') onPreviewLevelChanged() { Logger.Info(TAG, "Preview level: " + this.previewLevel); if (this.previewLevel == 4) { @@ -95,7 +96,7 @@ struct PreviewPage { this.eventOffsetY = (event.pinchCenterY - this.startPinchY) * this.previousScale; this.imageScale = this.previousScale * event.scale; }) - .onActionEnd((event: GestureEvent) => { + .onActionEnd((_event: GestureEvent) => { this.offsetX = (this.offsetX * this.imageScale + this.eventOffsetX) / this.imageScale; this.offsetY = (this.offsetY * this.imageScale + this.eventOffsetY) / this.imageScale; this.eventOffsetX = 0; diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 2c48107..d21fd89 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -124,7 +124,9 @@ export struct SettingsPage { } }) } + .background($r('sys.color.background_primary')) .height('100%') + .padding({ bottom: 16 }) } private doLoadSettings() { diff --git a/entry/src/main/ets/pages/TransferProgress.ets b/entry/src/main/ets/pages/TransferProgress.ets new file mode 100644 index 0000000..f942d02 --- /dev/null +++ b/entry/src/main/ets/pages/TransferProgress.ets @@ -0,0 +1,47 @@ +import { JobManager, TransferJob } from 'nextcloud'; + +@ComponentV2 +export struct TransferProgress { + @Builder + transferProgressItem(job: TransferJob) { + Column() { + Text(job.filename) + .textOverflow({ overflow: TextOverflow.MARQUEE }) + .textAlign(TextAlign.Start) + .width('100%') + + Row({ space: 16 }) { + Progress({ + value: job.transferredSize, + total: job.totalSize, + }) + .flexShrink(1) + + Text(job.progressPercentText) + .width(60) + .textAlign(TextAlign.End) + } + .width('100%') + } + .width('100%') + .borderRadius(16) + .padding(8) + .backgroundColor($r('sys.color.gray_03')) + } + + build() { + List({ space: 8 }) { + LazyForEach(JobManager.tasks, (item: TransferJob) => { + ListItem() { + this.transferProgressItem(item) + } + }, (item: TransferJob, _index: number) => { + return `${item.id}` + }) + } + .padding(16) + .width('100%') + .height('100%') + .background($r('sys.color.background_primary')) + } +} diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 360d44c..6983a26 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,21 +1,25 @@ -import { Category, Logger, PhotoAssetsManager, PhotoItem, UploadStatus } from 'nextcloud'; -import { LengthMetrics } from '@kit.ArkUI'; +import { Account, Category, Logger, Nextcloud, PhotoAssetsManager, PhotoItem, UploadStatus } from 'nextcloud'; const TAG = "Page-UploadPage" +const PHOTO_PREVIEW_SIZE = 75; @ComponentV2 struct PhotoView { + @Require @Param account: Account; @Require @Param asset: PhotoItem; @Require @Param category: Category; @Local private thumbnail: PixelMap | null = null; getUploadColor(status: UploadStatus): Color { switch (status) { - case UploadStatus.Uploaded: + case UploadStatus.Completed: return Color.Green; - case UploadStatus.Uploading: + case UploadStatus.Pending: return Color.Yellow; + case UploadStatus.Uploading: + return Color.White; case UploadStatus.Failed: + case UploadStatus.FailedCreatingPath: return Color.Red; case UploadStatus.None: return Color.Gray; @@ -29,112 +33,228 @@ struct PhotoView { build() { Stack() { Image(this.thumbnail) - .height(100) - .width(100) + .height(PHOTO_PREVIEW_SIZE) + .width(PHOTO_PREVIEW_SIZE) .objectFit(ImageFit.Cover) Text(this.asset.isVideo ? '▶' : '') - .fontSize(48) + .fontSize(24) .fontFamily('monospace') .fontColor(Color.Red) } .draggable(false) - .height(100) - .width(100) + .height(PHOTO_PREVIEW_SIZE) + .width(PHOTO_PREVIEW_SIZE) .borderWidth(3) .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { - await this.asset.upload("/instantupload/category/"); + let remoteFolderId = this.account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + if (remoteFolder === null) { + Logger.Info(TAG, `creating instant upload remote folder`) + // not configured, create new + remoteFolder = await Nextcloud.getSyncFolderManager().addRemoteFolder(this.account, '/InstantUpload/', 'Instant Upload'); + remoteFolderId = remoteFolder.id; + this.account.dbAccount.config.instantUploadFolderId = remoteFolder.id; + await Nextcloud.updateAccount(this.account); + Logger.Info(TAG, `created instant upload remote folder`) + } + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + this.asset.getUploadJob(this.category.name, remoteFolder).start(); }) .onAppear(async () => { if (this.thumbnail === null) { - this.thumbnail = await this.asset.getThumbnail({ height: 150, width: 150 }); + this.thumbnail = await this.asset.getThumbnail({ height: 200, width: 200 }); } }) } } -@Preview @ComponentV2 -export struct UploadPage { - @Local private categories: Map = PhotoAssetsManager.categories; +struct CategoryView { + @Require @Param account: Account; + @Require @Param category: Category; + @Local private isEditing: boolean = false; + @Local private isExpanded: boolean = false; + @Local private isSyncEnabled: boolean = false; - @Builder - photosGridView(category: Category) { - Stack() { - Grid() { - LazyForEach(category.visibleItems, (asset: PhotoItem, index: number) => { - GridItem() { - PhotoView({ asset: asset, category: category }) + aboutToAppear(): void { + if (this.category.instantUploadEnabled) { + this.isSyncEnabled = true; + } + } + + build() { + Column({ space: 8 }) { + Row({ space: 16 }) { + Row({ space: 8 }) { + if (this.isEditing) { + TextInput({ text: this.category.name }) + .fontWeight(FontWeight.Bold) + .flexShrink(1) + .fontSize(16) + .borderRadius(0) + .onSubmit(async (enterKey: EnterKeyType, event: SubmitEvent) => { + if (event.text.length === 0) { + this.getUIContext().getPromptAction().showDialog({ + title: `Category Rename`, + message: `Category name cannot be empty!`, + }).catch((e: Error) => { + Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); + }); + + event.keepEditableState(); + return; + } + Logger.Info(TAG, `category edit onSubmit: ${EnterKeyType[enterKey]}`) + this.category.setAlias(event.text); + Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); + }) + .onBlur(() => { + this.isEditing = false; + }) + } else { + Text(this.category.name) + .fontSize(24) + + SymbolGlyph($r('sys.symbol.doc_plaintext_and_pencil')) + .fontSize(12) + .fontColor([Color.Gray]) + .onClick(() => { + this.isEditing = true; + }) } - .width('100%') - .onAppear(() => { - if (index == category.visibleItems.totalCount() - 1) { - category.loadNextBatch(); - } - }) - }) - } - .layoutDirection(GridDirection.Row) - .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) }) - // .columnsTemplate('1fr 1fr 1fr 1fr') - .columnsTemplate('repeat(auto-fit, 100)') - .scrollBar(BarState.On) - .edgeEffect(EdgeEffect.Spring) - .columnsGap(8) - .rowsGap(8) - .cachedCount(10) - .width('100%') - .height('auto') + } + .flexShrink(1) + .height(36) - Column() { - Button($r('app.string.upload_all')) + Row({ space: 16 }) { + Row() { + Checkbox() + .select(this.isSyncEnabled) + .onChange((value: boolean) => { + this.toggleSyncEnable(value); + }) + Text(`Upload`) + } + + Button() { + SymbolGlyph(this.isExpanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(24) + .fontColor([$r('sys.color.font')]) + } .onClick(() => { - Logger.Info(TAG, `Upload all in '${category.name}'`); - // this.category.uploadAllAssets(); + this.isExpanded = !this.isExpanded; }) - .width('30%') + .padding(8) + .type(ButtonType.Circle) + .buttonStyle(ButtonStyleMode.TEXTUAL) + .backgroundColor($r('sys.color.gray_03')) + } } + .justifyContent(FlexAlign.SpaceBetween) .width('100%') - .position({ bottom: 5 }) - } - } - build() { - Tabs() { - ForEach(Array.from(this.categories), (category: [number, Category]) => { - if (category[1].visibleItems.totalCount() >= 10) { - TabContent() { - Flex({ direction: FlexDirection.Column, space: { main: LengthMetrics.px(20) } }) { - Row({ space: 16 }) { - TextInput({ text: category[1].name }) - .fontWeight(FontWeight.Bold) - .fontSize(24) - .onChange((newValue: string) => { - category[1].setAlias(newValue); - }) - .flexShrink(1) - - Text('category.location') - .flexShrink(0) - .constraintSize({ minWidth: 120 }) + if (this.isExpanded) { + Blank() + .height(6) + + Stack() { + Grid() { + LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { + GridItem() { + PhotoView({ asset: asset, category: this.category, account: this.account }) } - .justifyContent(FlexAlign.Center) - .height(60) .width('100%') + .onAppear(() => { + if (index == this.category.visibleItems.totalCount() - 1) { + this.category.loadNextBatch(); + } + }) + }) + } + .layoutDirection(GridDirection.Row) + .fadingEdge(true) + .columnsTemplate(`repeat(auto-fit, ${PHOTO_PREVIEW_SIZE})`) + .scrollBar(BarState.On) + .edgeEffect(EdgeEffect.Spring) + .columnsGap(8) + .rowsGap(8) + .cachedCount(10) + .width('100%') + .height('100%') + .margin({ bottom: 12 }) - this.photosGridView(category[1]) - } - .width('100%') - .height('100%') + Column() { + Button($r('app.string.upload_all')) + .onClick(() => { + Logger.Info(TAG, `Upload all in '${this.category.name}'`); + // this.category.uploadAllAssets(); + }) + .backgroundColor('# 0082C9') + .width('30%') } - .margin(10) - .tabBar(category[1].name) + .width('100%') + .position({ bottom: 10 }) } - }) + .width('100%') + .height('100%') + .constraintSize({ maxHeight: 300, minHeight: 100 }) + } + } + .borderRadius(16) + .padding({ + left: 12, + right: 12, + top: 8, + bottom: 8 + }) + .backgroundColor($r('sys.color.gray_02')) + .width('100%') + .height('auto') + } + + private async toggleSyncEnable(enabled: boolean) { + Logger.Info(TAG, `setting category ${this.category.name} sync enable to ${enabled}`); + this.isSyncEnabled = enabled; + this.category.localFolder.dbLocalFolder.config.instantUploadEnabled = this.isSyncEnabled; + await Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); + Logger.Info(TAG, `done`); + } +} + +@Preview +@ComponentV2 +export struct UploadPage { + @Require @Param account: Account; + @Local private categories: Category[] = []; + + aboutToAppear(): void { + Logger.Info(TAG, `aboutToAppear, found ${PhotoAssetsManager.categories.size} categories`); + this.categories = Array.from(PhotoAssetsManager.categories.values()); + this.categories.sort((a: Category, b: Category): number => { + if (a.instantUploadEnabled !== b.instantUploadEnabled) { + return a.instantUploadEnabled ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + } + + build() { + Column() { + List({ space: 10 }) { + Repeat(this.categories) + .each((item: RepeatItem) => { + CategoryView({ category: item.item, account: this.account }) + }) + } + .width('100%') + .height('auto') + .padding(16) + .flexShrink(1) } - .vertical(true) - .barWidth(100) - .barMode(BarMode.Scrollable) + .width('100%') + .height('100%') + .background($r('sys.color.background_primary')) } } diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets index b6a1e08..d019fa8 100644 --- a/entry/src/main/ets/utils/PhotoMonitor.ets +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -1,8 +1,6 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit'; -import { Logger, NCommon } from 'nextcloud'; -import { GlobalStorage } from './GlobalStorage'; +import { Logger, NCommon, PhotoAssetsManager } from 'nextcloud'; import { NotificationManager } from './NotificationManager'; -import { PhotoAssetsManager } from 'nextcloud'; const TAG = 'Utils-PhotoMonitor'; @@ -76,7 +74,7 @@ class PhotoMonitorImpl { }, PhotoMonitorImpl.UPLOAD_DELAY_MS); let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.UPLOAD_DELAY_MS); - NotificationManager.publishNotification('nextUploadTime', '', 1); + NotificationManager.publishNotification('nextUploadTime', '1', 1); Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); } } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets index fbe39d2..c91d315 100644 --- a/nextcloud/src/main/ets/Account/Account.ets +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -1,4 +1,6 @@ -import { DBAccount } from '../Database/DbTypes/DBAccount'; +import { AccountConfig, DBAccount } from '../Database/DbTypes/DBAccount'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { Nextcloud } from '../Main'; import { Base64Encode } from '../Utils/CommonUtils'; import { Logger } from '../Utils/Logger'; @@ -30,7 +32,10 @@ export class Account { public static createNewAccount(hostname: string, username: string, password: string): Account { Logger.Info(TAG, `createNewAccount: enter, hostname=${hostname}, username=${username}`); - let dbAccount = new DBAccount(0, hostname, username, password); + const accountConfig: AccountConfig = { + instantUploadFolderId: 0, + } + let dbAccount = new DBAccount(0, hostname, username, password, accountConfig); return new Account(dbAccount); } @@ -58,4 +63,24 @@ export class Account { let encodedPath = encodeURI(path); return this.baseUrlWithCredentials + `/remote.php/dav/files/${this.dbAccount.username}/${encodedPath}`; } + + public async getRemoteUploadFolder(): Promise { + if (this.dbAccount.config.instantUploadFolderId === 0) { + Logger.Info(TAG, `account did not have instant upload configured`); + return null; + } + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(this.dbAccount.config.instantUploadFolderId); + if (remoteFolder === null) { + Logger.Error(TAG, `failed to get remote folder`); + return null; + } + + if (remoteFolder.accountId !== this.id) { + Logger.Error(TAG, `remote folder accountId mismatches, ${remoteFolder.accountId} vs ${this.id}`) + this.dbAccount.config.instantUploadFolderId = 0; + return null; + } + + return remoteFolder; + } } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets index 45b405a..28c3833 100644 --- a/nextcloud/src/main/ets/Database/DBManager.ets +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -1,14 +1,14 @@ import { relationalStore } from '@kit.ArkData'; import { Logger } from '../Utils/Logger'; import { DBAccountTableInfo } from './DbTypes/DBAccount'; -import { DBLocalSyncFolderTable } from './DbTypes/DBLocalFolder'; +import { DBLocalFolderTable } from './DbTypes/DBLocalFolder'; import { DBRemoteFolderTable } from './DbTypes/DBRemoteFolder'; import { DEBUG } from '../../../../BuildProfile'; import { DBSyncConfigurationTable } from './DbTypes/DBSyncConfiguration'; const TAG = 'Database/DBManager'; -const CAN_RECREATE_DATABASE = true; +const CAN_RECREATE_DATABASE = false; interface ForeignKeyDescriptor { target: DBTableDescriptor; @@ -25,6 +25,7 @@ export interface DBTableDescriptor { readonly tableName: string, readonly columns: Record; readonly createItemFromBucket: (bucket: relationalStore.ValuesBucket) => TItem; + readonly primaryKey: TColumnName; } /** @@ -33,13 +34,15 @@ export interface DBTableDescriptor { export interface DBItem { getTableName(): string; + getTableDescription(): DBTableDescriptor; + toBucket(isCreate: boolean): relationalStore.ValuesBucket; } const TABLES: DBTableDescriptor[] = [ DBAccountTableInfo, DBRemoteFolderTable, - DBLocalSyncFolderTable, + DBLocalFolderTable, DBSyncConfigurationTable, ]; @@ -62,7 +65,7 @@ export class DBManager { Object.keys(fk.target.columns) .find( key => fk.target.columns[key] === - fk.targetColumn + fk.targetColumn )!; foreignKeyDefs.push(`FOREIGN KEY(${colName}) REFERENCES ${fk.target.tableName}(${fkTargetColumn})`); } @@ -189,11 +192,7 @@ export class DBManager { return result; } - async getItemById( - id: number, - column: ColumnDescriptor, - factory: DBTableDescriptor - ): Promise(id: number, table: DBTableDescriptor): Promise { Logger.Info(TAG, 'getItemById: enter'); if (!this.store) { @@ -201,8 +200,8 @@ export class DBManager { return null; } - let predicate = new relationalStore.RdbPredicates(factory.tableName); - predicate.equalTo(Object.keys(factory.columns).find(key => factory.columns[key] === column)!, id); + let predicate = new relationalStore.RdbPredicates(table.tableName); + predicate.equalTo(table.primaryKey, id); try { let resultSet = await this.store.query(predicate); @@ -212,7 +211,7 @@ export class DBManager { } if (resultSet.goToNextRow()) { let bucket = resultSet.getRow(); - let item = factory.createItemFromBucket(bucket); + let item = table.createItemFromBucket(bucket); Logger.Info(TAG, `getItemById: completed, found item with id=${id}`); return item; } @@ -310,7 +309,15 @@ export class DBManager { let bucket = object.toBucket(isCreate); try { - let rowId = await this.store.insert(object.getTableName(), bucket); + let rowId: number; + if (isCreate) { + rowId = await this.store.insert(object.getTableName(), bucket); + } else { + let pred = new relationalStore.RdbPredicates(object.getTableName()); + const primaryKey = object.getTableDescription().primaryKey; + pred.equalTo(primaryKey, bucket[primaryKey]); + rowId = await this.store.update(bucket, pred); + } Logger.Info(TAG, `doStoreItem: completed, rowId: ${rowId}`); this.store.commit(); return rowId; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets index fcda953..59cb00d 100644 --- a/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets @@ -4,19 +4,32 @@ import { relationalStore } from '@kit.ArkData'; const TAG = 'Database/DBAccount'; +/** + * Backwards compatible, do not change field names + */ +export interface AccountConfig { + instantUploadFolderId: number +} + export class DBAccount implements DBItem { static readonly DB_TABLE_NAME: string = 'Accounts'; id: number; username: string; password: string; hostname: string; + config: AccountConfig; - constructor(id: number, hostname: string, username: string, password: string) { + constructor(id: number, hostname: string, username: string, password: string, config: AccountConfig) { Logger.Debug(TAG, `constructor: enter, id=${id}, hostname=${hostname}, username=${username}`); this.id = id; this.hostname = hostname; this.username = username; this.password = password; + this.config = config; + } + + getTableDescription(): DBTableDescriptor { + return DBAccountTableInfo; } public getTableName(): string { @@ -31,11 +44,12 @@ export class DBAccount implements DBItem { bucket['username'] = this.username; bucket['password'] = this.password; bucket['hostname'] = this.hostname; + bucket['config'] = JSON.stringify(this.config); return bucket; } } -type DBAccountColumnNames = 'account_id' | 'hostname' | 'username' | 'password'; +type DBAccountColumnNames = 'account_id' | 'hostname' | 'username' | 'password' | 'config'; export const DBAccountTableInfo: DBTableDescriptor = { tableName: DBAccount.DB_TABLE_NAME, @@ -44,12 +58,15 @@ export const DBAccountTableInfo: DBTableDescriptor { + return DBLocalFolderTable; } public getTableName(): string { - return DBLocalSyncFolder.DB_TABLE_NAME; + return DBLocalFolder.DB_TABLE_NAME; } public toBucket(isCreate: boolean): relationalStore.ValuesBucket { @@ -40,6 +51,7 @@ export class DBLocalSyncFolder implements DBItem { bucket['local_path'] = this.localPath; bucket['alias'] = this.alias; bucket['folder_type'] = this.folderType; + bucket['config'] = JSON.stringify(this.config); return bucket; } @@ -48,22 +60,24 @@ export class DBLocalSyncFolder implements DBItem { } } -type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'folder_type'; +type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'folder_type' | 'config'; -export const DBLocalSyncFolderTable: DBTableDescriptor = { - tableName: DBLocalSyncFolder.DB_TABLE_NAME, +export const DBLocalFolderTable: DBTableDescriptor = { + tableName: DBLocalFolder.DB_TABLE_NAME, columns: { 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, 'local_path': { type: 'TEXT', attrs: 'NOT NULL' }, 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, 'folder_type': { type: 'INTEGER', attrs: 'NOT NULL' }, + 'config': { type: 'TEXT', attrs: 'NOT NULL' }, }, - - createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalSyncFolder { + primaryKey: 'folder_id', + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalFolder { let id = bucket['folder_id'] as number; let localPath = bucket['local_path'] as string; let alias = bucket['alias'] as string; let folderType = bucket['folder_type'] as number; - return new DBLocalSyncFolder(id, localPath, alias, folderType); + let config: DBLocalFolderConfig = JSON.parse(bucket['config'] as string | undefined ?? '{}'); + return new DBLocalFolder(id, localPath, alias, folderType, config); } }; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets index ce20ab3..9834c2e 100644 --- a/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets @@ -24,6 +24,10 @@ export class DBRemoteFolder implements DBItem { return DBRemoteFolder.DB_TABLE_NAME; } + getTableDescription(): DBTableDescriptor { + return DBRemoteFolderTable; + } + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { let bucket: relationalStore.ValuesBucket = {}; if (isCreate === false) { @@ -53,6 +57,7 @@ export const DBRemoteFolderTable: DBTableDescriptor { + return DBSyncConfigurationTable; + } + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { let bucket: relationalStore.ValuesBucket = {}; if (isCreate === false) { @@ -43,8 +47,8 @@ export const DBSyncConfigurationTable: DBTableDescriptor 0 && prefUsername.length > 0 && prefPassword.length > 0) { Logger.Info(TAG, `migrateAccount: no accounts found, migrate default account '${prefUsername}@${prefServer}' from preferences`); - let dbAccount = new DBAccount(0, prefServer, prefUsername, prefPassword); - await this.addAccount(new Account(dbAccount)); + let account = Account.createNewAccount(prefServer, prefUsername, prefPassword); + await this.addAccount(account); } else { Logger.Info(TAG, `migrateAccount: no accounts found, possibly first run`); } diff --git a/nextcloud/src/main/ets/SyncEngine/Category.ets b/nextcloud/src/main/ets/SyncEngine/Category.ets index 9fb87a1..755ad95 100644 --- a/nextcloud/src/main/ets/SyncEngine/Category.ets +++ b/nextcloud/src/main/ets/SyncEngine/Category.ets @@ -20,10 +20,9 @@ export enum AddPosition { const BATCH_SIZE = 150; -@ObservedV2 export class Category { visibleItems: ImageListDataSource = new ImageListDataSource(); - private localFolder: LocalFolder; + localFolder: LocalFolder; constructor(localFolder: LocalFolder) { if (localFolder.folderType !== LocalFolderType.PhotoAlbum) { @@ -37,6 +36,10 @@ export class Category { return this.localFolder.alias; } + public get instantUploadEnabled() { + return this.localFolder.dbLocalFolder.config.instantUploadEnabled; + } + public setAlias(newAlias: string) { this.localFolder.alias = newAlias; } diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets index 174e8bc..918aab2 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets @@ -9,27 +9,38 @@ export enum JobState { export interface JobData {} +type JobStateListener = (job: BaseJob, state: JobState) => void; + export class BaseJob { public readonly jobName: string; protected readonly TAG: string; protected state: JobState = JobState.PENDING; - private stateListeners: Array<(state: string) => void> = []; + private stateListeners: Array = []; + private stateHistory: JobState[] = []; constructor(name: string) { this.jobName = name; this.TAG = `Jobs/${this.jobName}`; + this.stateHistory.push(JobState.PENDING); } public get jobState(): string { return this.state; } - public addStateListener(listener: (state: string) => void): void { - this.stateListeners.push(listener); + public addStateListener(listener: JobStateListener): void { + this.stateListeners.push(listener as JobStateListener); + for (const state of this.stateHistory) { + listener(this, state); + } } - public removeStateListener(listener: (state: string) => void): void { - this.stateListeners = this.stateListeners.filter(l => l !== listener); + public removeStateListener(listener?: JobStateListener): void { + if (listener === undefined) { + this.stateListeners = []; + } else { + this.stateListeners = this.stateListeners.filter(l => l !== listener); + } } public start(): Promise { @@ -59,6 +70,7 @@ export class BaseJob { private setState(newState: JobState) { this.state = newState; - this.stateListeners.forEach(listener => listener(this.state)); + this.stateHistory.push(newState); + this.stateListeners.forEach(listener => listener(this, newState)); } } diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets index 1889411..cc68336 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets @@ -2,13 +2,31 @@ import { TransferJob, TransferJobData } from './TransferJob'; import { Logger } from '../../Utils/Logger'; import { rcp } from '@kit.RemoteCommunicationKit'; import { TransferType } from '../TransferManagerEventIPC'; +import { LocalFolder } from '../../Database/Types/LocalFolder'; -export interface DownloadJobData extends TransferJobData {} +export interface DownloadJobData extends TransferJobData { + /** + * The local folder involved in the transfer + */ + localFolder: LocalFolder; + + /** + * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: + * localPath = localFolder.path + relativePath + * remotePath = remoteFolder.path + relativePath + */ + relativePath: string; +} @ObservedV2 export class DownloadTransferJob extends TransferJob { + @Trace public localFolder: LocalFolder; + @Trace public relativePath: string; + constructor(id: number, data: DownloadJobData) { super(id, TransferType.Download, data); + this.localFolder = data.localFolder; + this.relativePath = data.relativePath; } protected async onTransferWork(session: rcp.Session) { diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets index a53bf7c..5c04676 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets @@ -1,4 +1,3 @@ -import { LocalFolder } from '../../Database/Types/LocalFolder'; import { RemoteFolder } from '../../Database/Types/RemoteFolder'; import { Logger } from '../../Utils/Logger'; import { CreateSession } from '../../webdav/TransferUtils'; @@ -11,22 +10,10 @@ import { rcp } from '@kit.RemoteCommunicationKit'; export interface TransferJobData extends JobData { fileName: string; - /** - * The local folder involved in the transfer - */ - localFolder: LocalFolder; - /** * The remote folder involved in the transfer */ remoteFolder: RemoteFolder; - - /** - * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: - * localPath = localFolder.path + relativePath - * remotePath = remoteFolder.path + relativePath - */ - relativePath: string; } @ObservedV2 @@ -36,9 +23,7 @@ export class TransferJob extends BaseJob { @Trace public readonly filename: string; @Trace public totalSize: number = 1; @Trace public transferredSize: number = 0; - @Trace public localFolder: LocalFolder; @Trace public remoteFolder: RemoteFolder; - @Trace public relativePath: string; @Trace protected historicalBatchesTransferredSize: number = 0; private readonly sessionExtraHeaders: Map = new Map(); @@ -47,9 +32,7 @@ export class TransferJob extends BaseJob { this.id = id; this.type = type; this.filename = data.fileName; - this.localFolder = data.localFolder; this.remoteFolder = data.remoteFolder; - this.relativePath = data.relativePath; if (extraHeaders) { this.sessionExtraHeaders = extraHeaders; } @@ -124,7 +107,6 @@ export class TransferJob extends BaseJob { } protected onUploadProgress(transferredBytes: number) { - Logger.Info(this.TAG, `onUploadProgress: transferredBytes=${transferredBytes}`); this.transferredSize = this.historicalBatchesTransferredSize + transferredBytes; } diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets index c20f05d..ebd7ed3 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets @@ -1,4 +1,4 @@ -import { IsRemotePathExists } from '../../webdav/TransferUtils'; +import { CreatePath, IsRemotePathExists } from '../../webdav/TransferUtils'; import { TransferJob, TransferJobData } from './TransferJob'; import { fileIo } from '@kit.CoreFileKit'; import { Logger } from '../../Utils/Logger'; @@ -6,6 +6,11 @@ import { rcp } from '@kit.RemoteCommunicationKit'; import { TransferType } from '../TransferManagerEventIPC'; import { NCommon } from '../../Common'; +export interface UploadCallback { + jobCreated, + aboutToUpload, +} + export interface UploadJobData extends TransferJobData { /** * Local item modified time in milliseconds since epoch @@ -16,6 +21,13 @@ export interface UploadJobData extends TransferJobData { * Uri of the local item */ uri: string; + + /** + * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: + * localPath = localFolder.path + relativePath + * remotePath = remoteFolder.path + relativePath + */ + relativePath: string; } @ObservedV2 @@ -24,11 +36,13 @@ export class UploadTransferJob extends TransferJob { private readonly uri: string; private readonly file: fileIo.File; private readonly fileStat: fileIo.Stat; + private readonly relativePath: string; constructor(id: number, data: UploadJobData) { let extraHeaders = new Map(); extraHeaders.set('X-OC-MTime', `${Math.round(data.mTime / 1000)}`); super(id, TransferType.Upload, data, extraHeaders); + this.relativePath = data.relativePath; this.uri = data.uri; try { this.fileStat = fileIo.statSync(this.uri); @@ -48,21 +62,29 @@ export class UploadTransferJob extends TransferJob { Logger.Info(this.TAG, `doWork enter`); const account = this.remoteFolder.account; const destPath = this.remoteFolder.getFullPath(this.relativePath); - const destFileFullPath = account.remoteBaseDirUrl() + destPath; + const destFile = destPath + '/' + this.filename; - let shouldReplace = true // NCommon.Preference.replaceExistingFiles; + let shouldReplace = NCommon.Preference.replaceExistingFiles; if (!shouldReplace) { - if (await IsRemotePathExists(session, account, destPath)) { - Logger.Info(this.TAG, `file already exists: ${destPath}, not replacing as per settings`); + if (await IsRemotePathExists(session, account, destFile)) { + Logger.Info(this.TAG, `file already exists: ${destFile}, not replacing as per settings`); session.close(); this.uploadStatusCode = 204; // No Content, file already exists return; } } + { + let mkdirResult = await CreatePath(this.remoteFolder.account, destPath); + if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { + Logger.Error(this.TAG, `failed to create path: ${destPath}`) + this.uploadStatusCode = mkdirResult; + return; + } + } const tempDirectory = NCommon.RandomString(16); const chunkedUploadRemoteDir = account.chunkedUploadBaseUrl() + tempDirectory + `/`; - session.configuration!.headers!['Destination'] = destFileFullPath; + session.configuration!.headers!['Destination'] = account.remoteBaseDirUrl() + destFile; try { let resp = await session.fetch(new rcp.Request(chunkedUploadRemoteDir, "MKCOL", {})); @@ -121,4 +143,4 @@ export class UploadTransferJob extends TransferJob { } } -const DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024; // 20 MB +const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets index b7bda7b..c039bbb 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -31,6 +31,10 @@ class PhotoAssetsManagerImpl { ]; @Trace public categories: Map = new Map(); + /** + * @throws + * @returns + */ public async loadNextBatchFromAlbumId(albumId: number, offset: number, limit: number): Promise { const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, @@ -42,7 +46,7 @@ class PhotoAssetsManagerImpl { let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); let assets = await result.getAllObjects(); - Logger.Info(TAG, `loadNextBatchFromAlbumId:` + + Logger.Debug(TAG, `loadNextBatchFromAlbumId:` + `albumId: ${albumId}`, `offset: ${offset}`, `limit: ${limit} -> ${assets.length} assets` @@ -56,7 +60,6 @@ class PhotoAssetsManagerImpl { public async initialize(categories: Map) { for (let item of categories) { - Logger.Debug(TAG, `existing id: ${item[1].albumId}`) let localFolder = item[1]; if (localFolder.folderType === LocalFolderType.PhotoAlbum) { this.categories.set(localFolder.albumId, new Category(localFolder)); @@ -67,9 +70,9 @@ class PhotoAssetsManagerImpl { const albums = await this.getAllCategories(); for (let albumId of albums) { if (!this.categories.has(albumId)) { - Logger.Warn(TAG, `found new AlbumId: ${albumId}`); + Logger.Warn(TAG, `initialize: found new AlbumId: ${albumId}`); let localFolder = await sfm.addLocalAlbumFolder(albumId, `Album ${albumId}`); - this.categories.set(localFolder.id, new Category(localFolder)); + this.categories.set(localFolder.albumId, new Category(localFolder)); } } } @@ -136,6 +139,12 @@ class PhotoAssetsManagerImpl { let category = this.categories.get(ownerAlbumId); if (category) { category.tryAddAsset(photoItem, addPosition); + + let account = Nextcloud.getAllAccounts()[0]; + let remoteFolderId = account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + photoItem.getUploadJob(category.name, remoteFolder!).start(); } else { Logger.Error(TAG, `add new category for ${ownerAlbumId} failed`); } diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets index c9d8600..3602fef 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets @@ -1,30 +1,30 @@ import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { CreatePath, JobManager, Logger, Nextcloud, SyncConfiguration } from '../Index'; -import { promptAction } from '@kit.ArkUI'; +import { JobManager } from '../Index'; import { image } from '@kit.ImageKit'; import { FormatTime } from '../Utils/CommonUtils'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { BaseJob, JobState } from './Jobs/BaseJob'; +import { UploadTransferJob } from './Jobs/UploadTransferJob'; +import { Logger } from '../Utils/Logger'; const TAG = 'PhotoItem'; const DEFAULT_FORMAT = 'yyyy/MM/'; export enum UploadStatus { - None, - Uploading, - Uploaded, - Failed, - Existed, -} - -export enum UploadResult { - FAILED, - EXISTED, - SUCCEEDED, + None, // not yet started + Pending, // scheduled, not yet transmitted + Uploading, // transmitting + Existed, // existed + Completed, // upload completed + Failed, // upload failed + FailedCreatingPath, // failed to create one or more parent path } @ObservedV2 export class PhotoItem { @Trace uploadStatus: UploadStatus = UploadStatus.None; private innerAsset: PHelper.PhotoAsset; + private uploadJob: Optional = undefined; constructor(asset: PHelper.PhotoAsset) { this.innerAsset = asset; @@ -70,54 +70,41 @@ export class PhotoItem { return await this.innerAsset.getThumbnail(size); } - async upload(basePath: string): Promise { - const configuration = Nextcloud.getSyncFolderManager().allSyncConfigurations.values(); - let realConfiguration = configuration.next().value as SyncConfiguration; - - this.uploadStatus = UploadStatus.Uploading; - const path = basePath + FormatTime(this.dateTaken, DEFAULT_FORMAT); - let mkdirResult = await CreatePath(realConfiguration.remoteFolder.account, path); - if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { - // 405 is the status code for "already exists" - Logger.Error(TAG, `Failed to create path: ${path}`); - promptAction.showToast({ - message: `Failed to create path: ${path}: ${mkdirResult}`, - duration: 2000, - }); + public getUploadJob(categoryName: string, remoteFolder: RemoteFolder): UploadTransferJob { + Logger.Info(TAG, `getUploadJob: enter`); + if (this.uploadJob) { + return this.uploadJob; } - let ret: UploadResult = UploadResult.FAILED; - - let job = JobManager.createUploadJob({ + this.uploadJob = JobManager.createUploadJob({ fileName: this.displayName, mTime: this.dateTakenMs, uri: this.uri, - localFolder: realConfiguration.localFolder, - remoteFolder: realConfiguration.remoteFolder, - relativePath: path + '/' + this.displayName, + remoteFolder: remoteFolder, + relativePath: categoryName + '/' + FormatTime(this.dateTaken, DEFAULT_FORMAT) + '/', }); - await job.start(); - - let message = ""; - let uploadResult = job.statusCode; - if (uploadResult !== 201 && uploadResult !== 204) { - message = `Failed: ${this.displayName}: ${uploadResult}`; - this.uploadStatus = UploadStatus.Failed; - ret = UploadResult.FAILED; - } else if (uploadResult === 204) { - message = `File Existed: ${this.displayName}`; - this.uploadStatus = UploadStatus.Existed; - ret = UploadResult.EXISTED; - } else { - message = `Uploaded: ${this.displayName}`; - this.uploadStatus = UploadStatus.Uploaded; - ret = UploadResult.SUCCEEDED; - } - Logger.Info(TAG, `upload result: "${message}"`); - if (ret !== UploadResult.EXISTED) { - promptAction.showToast({ message, duration: 2000 }); - } - return ret; + this.uploadJob.addStateListener((baseJob: BaseJob, state: JobState) => { + switch (state) { + case JobState.PENDING: + this.uploadStatus = UploadStatus.Pending; + break; + case JobState.IN_PROGRESS: + this.uploadStatus = UploadStatus.Uploading; + break; + case JobState.COMPLETED: + case JobState.FAILED: { + let uploadJob = baseJob as UploadTransferJob; + if (uploadJob.statusCode === 201) { + this.uploadStatus = UploadStatus.Completed; + } else if (uploadJob.statusCode === 204) { + this.uploadStatus = UploadStatus.Existed; + } else { + this.uploadStatus = UploadStatus.Failed; + } + } + } + }); + return this.uploadJob; } } diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 9417e94..9ab777c 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -4,7 +4,7 @@ import { DBManager } from '../Database/DBManager'; import { DBSyncConfiguration, DBSyncConfigurationTable } from '../Database/DbTypes/DBSyncConfiguration'; import { LocalFolder } from '../Database/Types/LocalFolder'; import { RemoteFolder } from '../Database/Types/RemoteFolder'; -import { DBLocalSyncFolder, DBLocalSyncFolderTable, LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; +import { DBLocalFolder, DBLocalFolderConfig, DBLocalFolderTable, LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; import { DBRemoteFolder, DBRemoteFolderTable } from '../Database/DbTypes/DBRemoteFolder'; import { Account } from '../Account/Account'; import { SyncConfiguration } from '../Database/Types/SyncConfiguration'; @@ -41,19 +41,25 @@ export class SyncFolderManager { } public async addLocalAlbumFolder(albumId: number, alias: string): Promise { - Logger.Info(TAG, `addLocalFolder: enter`); - let dbLocalFolder = new DBLocalSyncFolder(0, albumId.toString(), alias, LocalFolderType.PhotoAlbum); + Logger.Info(TAG, `addLocalAlbumFolder: enter`); + let config: DBLocalFolderConfig = { + instantUploadEnabled: false + }; + let dbLocalFolder = new DBLocalFolder(0, albumId.toString(), alias, LocalFolderType.PhotoAlbum, config); let localFolder = new LocalFolder(dbLocalFolder); let insertedId = await this.dbManager_.createItem(dbLocalFolder); dbLocalFolder.id = insertedId!; this.localFolders_.set(dbLocalFolder.id, localFolder); - Logger.Info(TAG, `addLocalFolder: added local folder id ${dbLocalFolder.id} with albumId ${albumId}`); + Logger.Info(TAG, `addLocalAlbumFolder: added local folder id ${dbLocalFolder.id} with albumId ${albumId}`); return localFolder; } public async addLocalFolder(localPath: string, alias: string): Promise { Logger.Info(TAG, `addLocalFolder: enter`); - let dbLocalFolder = new DBLocalSyncFolder(0, localPath, alias, LocalFolderType.Regular); + let config: DBLocalFolderConfig = { + instantUploadEnabled: false + }; + let dbLocalFolder = new DBLocalFolder(0, localPath, alias, LocalFolderType.Regular, config); let localFolder = new LocalFolder(dbLocalFolder); let insertedId = await this.dbManager_.createItem(dbLocalFolder); dbLocalFolder.id = insertedId!; @@ -100,7 +106,7 @@ export class SyncFolderManager { public async loadSyncFolders(): Promise { Logger.Info(TAG, `loadSyncFolders: enter`); - let localFolders = await this.dbManager_.getItems(DBLocalSyncFolderTable); + let localFolders = await this.dbManager_.getItems(DBLocalFolderTable); for (let localFolder of localFolders) { this.localFolders_.set(localFolder.id, new LocalFolder(localFolder)); } @@ -133,45 +139,46 @@ export class SyncFolderManager { Logger.Info(TAG, `loadSyncFolders: loaded total ${this.configurations_.size} sync configurations`); } - private async getLocalFolderById(id: number): Promise { - let existing = this.localFolders_.get(id); + public async getRemoteFolderById(id: number): Promise { + let existing = this.remoteFolders_.get(id); if (existing) { return existing; } - let dbLocalFolder = - await this.dbManager_.getItemById(id, DBLocalSyncFolderTable.columns.folder_id, DBLocalSyncFolderTable); - if (!dbLocalFolder) { - Logger.Error(TAG, `getLocalFolderById: local folder id ${id} not found`); + let dbRemoteFolder = await this.dbManager_.getItemById(id, DBRemoteFolderTable); + if (!dbRemoteFolder) { + Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); return null; } - Logger.Info(TAG, `getLocalFolderById: ${dbLocalFolder.id}`); - existing = new LocalFolder(dbLocalFolder); - this.localFolders_.set(id, existing); + let account = Nextcloud.getAccountById(dbRemoteFolder.accountId); + existing = new RemoteFolder(account, dbRemoteFolder); + this.remoteFolders_.set(id, existing); return existing; } - private async getRemoteFolderById(id: number): Promise { - let existing = this.remoteFolders_.get(id); + public async updateLocalFolder(localFolder: LocalFolder) { + Logger.Info(TAG, `updateLocalFolder enter: id = ${localFolder.id}`); + let result = await this.dbManager_.updateItem(localFolder.dbLocalFolder); + Logger.Info(TAG, `updateLocalFolder result: ${result}`); + } + + private async getLocalFolderById(id: number): Promise { + let existing = this.localFolders_.get(id); if (existing) { return existing; } - let dbRemoteFolder = - await this.dbManager_.getItemById( - id, - DBRemoteFolderTable.columns.folder_id, - DBRemoteFolderTable - ); - if (!dbRemoteFolder) { - Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); + let dbLocalFolder = + await this.dbManager_.getItemById(id, DBLocalFolderTable); + if (!dbLocalFolder) { + Logger.Error(TAG, `getLocalFolderById: local folder id ${id} not found`); return null; } - let account = Nextcloud.getAccountById(dbRemoteFolder.accountId); - existing = new RemoteFolder(account, dbRemoteFolder); - this.remoteFolders_.set(id, existing); + Logger.Info(TAG, `getLocalFolderById: ${dbLocalFolder.id}`); + existing = new LocalFolder(dbLocalFolder); + this.localFolders_.set(id, existing); return existing; } } diff --git a/nextcloud/src/main/module.json5 b/nextcloud/src/main/module.json5 index 37f8f13..aac657b 100644 --- a/nextcloud/src/main/module.json5 +++ b/nextcloud/src/main/module.json5 @@ -10,6 +10,10 @@ "requestPermissions": [ { "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.READ_IMAGEVIDEO", + "reason": "$string:page_show", } ] } From a2e8a729697716f205d13e2e347ea1d5d45c7125 Mon Sep 17 00:00:00 2001 From: Moody Date: Sun, 1 Feb 2026 00:04:47 +0800 Subject: [PATCH 13/16] bulk upload --- entry/patch.json | 2 +- .../main/ets/entryability/EntryAbility.ets | 1 - entry/src/main/ets/pages/TransferProgress.ets | 34 ++++-- entry/src/main/ets/pages/UploadPage.ets | 72 ++++++++---- entry/src/main/ets/utils/GlobalStorage.ets | 7 +- .../main/ets/utils/NotificationManager.ets | 47 ++++---- entry/src/main/ets/utils/PhotoMonitor.ets | 4 +- .../main/ets/Database/Types/LocalFolder.ets | 2 - nextcloud/src/main/ets/Index.ets | 3 + .../src/main/ets/SyncEngine/Category.ets | 106 +++++++++++------- .../main/ets/SyncEngine/Jobs/TransferJob.ets | 10 +- .../ets/SyncEngine/PhotoAssetsManager.ets | 24 ++-- .../src/main/ets/SyncEngine/PhotoItem.ets | 20 ++-- .../src/main/ets/Utils/BasicDatasource.ets | 5 + 14 files changed, 212 insertions(+), 125 deletions(-) diff --git a/entry/patch.json b/entry/patch.json index a17bbd0..7c64dd8 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2001271, + "patchVersionCode": 2001276, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index fc39568..0780611 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -3,7 +3,6 @@ import { window } from '@kit.ArkUI'; import { AsyncSleep, InitialiseNextcloud, Logger, NCommon, Nextcloud, PhotoAssetsManager } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; -import { NotificationManager } from '../utils/NotificationManager'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { deviceInfo } from '@kit.BasicServicesKit'; import { statusBarManager } from '@kit.DeskTopExtensionKit'; diff --git a/entry/src/main/ets/pages/TransferProgress.ets b/entry/src/main/ets/pages/TransferProgress.ets index f942d02..5b67ee3 100644 --- a/entry/src/main/ets/pages/TransferProgress.ets +++ b/entry/src/main/ets/pages/TransferProgress.ets @@ -1,4 +1,5 @@ import { JobManager, TransferJob } from 'nextcloud'; +import { ShowToast } from '../utils/Utils'; @ComponentV2 export struct TransferProgress { @@ -26,22 +27,35 @@ export struct TransferProgress { .width('100%') .borderRadius(16) .padding(8) - .backgroundColor($r('sys.color.gray_03')) + .backgroundColor($r('sys.color.gray_02')) } build() { - List({ space: 8 }) { - LazyForEach(JobManager.tasks, (item: TransferJob) => { - ListItem() { - this.transferProgressItem(item) - } - }, (item: TransferJob, _index: number) => { - return `${item.id}` - }) + Column({ space: 16 }) { + List({ space: 8 }) { + LazyForEach(JobManager.tasks, (item: TransferJob) => { + ListItem() { + this.transferProgressItem(item) + } + }, (item: TransferJob, _index: number) => { + return `${item.id}` + }) + } + .width('100%') + .height('100%') + .flexShrink(1) + .flexGrow(1) + + Button('Clear') + .onClick(() => { + JobManager.tasks.clear(); + ShowToast(this.getUIContext(), `Cleared!`, 2000); + }) + .backgroundColor(Color.Red) } .padding(16) + .background($r('sys.color.background_primary')) .width('100%') .height('100%') - .background($r('sys.color.background_primary')) } } diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 6983a26..3899cf5 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,8 +1,33 @@ -import { Account, Category, Logger, Nextcloud, PhotoAssetsManager, PhotoItem, UploadStatus } from 'nextcloud'; +import { + Account, + Category, + Logger, + Nextcloud, + PhotoAssetsManager, + RemoteFolder, + PhotoItem, + UploadStatus, +} from 'nextcloud'; const TAG = "Page-UploadPage" const PHOTO_PREVIEW_SIZE = 75; +async function getUploadRemoteFolder(account: Account): Promise { + let remoteFolderId = account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + if (remoteFolder === null) { + Logger.Info(TAG, `creating instant upload remote folder`) + // not configured, create new + remoteFolder = await Nextcloud.getSyncFolderManager().addRemoteFolder(account, '/InstantUpload/', 'Instant Upload'); + remoteFolderId = remoteFolder.id; + account.dbAccount.config.instantUploadFolderId = remoteFolder.id; + await Nextcloud.updateAccount(account); + Logger.Info(TAG, `created instant upload remote folder`) + } + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + return remoteFolder!; +} + @ComponentV2 struct PhotoView { @Require @Param account: Account; @@ -15,9 +40,9 @@ struct PhotoView { case UploadStatus.Completed: return Color.Green; case UploadStatus.Pending: - return Color.Yellow; + return Color.Pink; case UploadStatus.Uploading: - return Color.White; + return Color.Yellow; case UploadStatus.Failed: case UploadStatus.FailedCreatingPath: return Color.Red; @@ -48,18 +73,7 @@ struct PhotoView { .borderWidth(3) .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { - let remoteFolderId = this.account.dbAccount.config.instantUploadFolderId; - let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); - if (remoteFolder === null) { - Logger.Info(TAG, `creating instant upload remote folder`) - // not configured, create new - remoteFolder = await Nextcloud.getSyncFolderManager().addRemoteFolder(this.account, '/InstantUpload/', 'Instant Upload'); - remoteFolderId = remoteFolder.id; - this.account.dbAccount.config.instantUploadFolderId = remoteFolder.id; - await Nextcloud.updateAccount(this.account); - Logger.Info(TAG, `created instant upload remote folder`) - } - Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + const remoteFolder = await getUploadRemoteFolder(this.account); this.asset.getUploadJob(this.category.name, remoteFolder).start(); }) .onAppear(async () => { @@ -96,12 +110,15 @@ struct CategoryView { .borderRadius(0) .onSubmit(async (enterKey: EnterKeyType, event: SubmitEvent) => { if (event.text.length === 0) { - this.getUIContext().getPromptAction().showDialog({ - title: `Category Rename`, - message: `Category name cannot be empty!`, - }).catch((e: Error) => { - Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); - }); + this.getUIContext() + .getPromptAction() + .showDialog({ + title: `Category Rename`, + message: `Category name cannot be empty!`, + }) + .catch((e: Error) => { + Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); + }); event.keepEditableState(); return; @@ -164,6 +181,7 @@ struct CategoryView { LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { GridItem() { PhotoView({ asset: asset, category: this.category, account: this.account }) + .enabled(this.isSyncEnabled) } .width('100%') .onAppear(() => { @@ -187,13 +205,15 @@ struct CategoryView { Column() { Button($r('app.string.upload_all')) - .onClick(() => { + .onClick(async () => { Logger.Info(TAG, `Upload all in '${this.category.name}'`); - // this.category.uploadAllAssets(); + const remoteFolder = await getUploadRemoteFolder(this.account); + this.category.uploadAllAssets(remoteFolder); }) - .backgroundColor('# 0082C9') + .backgroundColor(this.isSyncEnabled ? '#0082C9' : 'gray') .width('30%') } + .enabled(this.isSyncEnabled) .width('100%') .position({ bottom: 10 }) } @@ -221,6 +241,10 @@ struct CategoryView { await Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); Logger.Info(TAG, `done`); } + + private async uploadAllAssets() { + + } } @Preview diff --git a/entry/src/main/ets/utils/GlobalStorage.ets b/entry/src/main/ets/utils/GlobalStorage.ets index 7e1567b..220c387 100644 --- a/entry/src/main/ets/utils/GlobalStorage.ets +++ b/entry/src/main/ets/utils/GlobalStorage.ets @@ -1,5 +1,8 @@ export const INSTANT_UPLOAD_PATH = '/InstantUpload/'; -export class GlobalStorage { - public static SandboxBasePath: string = ""; +@ObservedV2 +class GlobalStorageImpl { + @Trace public SandboxBasePath: string = ""; } + +export const GlobalStorage = new GlobalStorageImpl(); diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets index 843ccff..625e8c7 100644 --- a/entry/src/main/ets/utils/NotificationManager.ets +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -3,44 +3,55 @@ import { notificationManager } from '@kit.NotificationKit'; const TAG = "Util-NotificationManager"; -class Notification { - private notificationId: Optional = undefined; +export class Notification { + private static nextId = 1; + private readonly nId: number; - public async cancelNotification(timeout: number = 0) { - if (this.notificationId === undefined) { + private constructor(nId: number) { + this.nId = nId; + } + + public static async publishNotification(title: string, content: string, percent: number): Promise { + let n = new Notification(Notification.nextId++); + await n.publishNotification(title, content, percent); + return n; + } + + public async cancel(timeout: number = 0) { + if (this.nId === undefined) { Logger.Warn(TAG, "cancelNotification: notificationId is undefined, return directly"); return; } if (timeout) { setTimeout(async () => { - await this.doCancelNotification(); + await this.doCancel(); }, timeout); } else { - await this.doCancelNotification(); + await this.doCancel(); } } public async publishNotification(title: string, content: string, percent: number) { - if (this.notificationId === undefined) { + if (this.nId === undefined) { Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); return; } - await this.doPublishNotification(title, content, percent); + await this.doPublish(title, content, percent); } - private async doCancelNotification() { + private async doCancel() { try { - await notificationManager.cancel(this.notificationId); - Logger.Info(TAG, `doCancelNotification: cancelled notification id ${this.notificationId}`); + await notificationManager.cancel(this.nId); + Logger.Info(TAG, `doCancelNotification: cancelled notification id ${this.nId}`); } catch (err) { Logger.Error(TAG, `doCancelNotification: cancel fail: ${JSON.stringify(err)}`); } } - private async doPublishNotification(title: string, subtitle: string, progress: number) { - Logger.Info(TAG, `doPublishNotification: '${title}', '${subtitle}', ${progress}% -> ${this.notificationId}`); + private async doPublish(title: string, subtitle: string, progress: number) { + Logger.Info(TAG, `doPublishNotification: '${title}', '${subtitle}', ${progress}% -> ${this.nId}`); let downloadTemplate: notificationManager.NotificationTemplate = { name: 'downloadTemplate', data: { @@ -51,7 +62,7 @@ class Notification { }; let notificationRequest: notificationManager.NotificationRequest = { - id: this.notificationId, + id: this.nId, content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { @@ -69,11 +80,3 @@ class Notification { } } } - -export namespace NotificationManager { - export async function publishNotification(title: string, content: string, percent: number): Promise { - let notification = new Notification(); - notification.publishNotification(title, content, percent); - return notification; - } -} diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets index d019fa8..171fbba 100644 --- a/entry/src/main/ets/utils/PhotoMonitor.ets +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -1,6 +1,6 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { Logger, NCommon, PhotoAssetsManager } from 'nextcloud'; -import { NotificationManager } from './NotificationManager'; +import { Notification } from './NotificationManager'; const TAG = 'Utils-PhotoMonitor'; @@ -74,7 +74,7 @@ class PhotoMonitorImpl { }, PhotoMonitorImpl.UPLOAD_DELAY_MS); let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.UPLOAD_DELAY_MS); - NotificationManager.publishNotification('nextUploadTime', '1', 1); + // Notification.publishNotification('nextUploadTime', '1', 1); Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); } } diff --git a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets index 9aa3b79..ec6bd53 100644 --- a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets +++ b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets @@ -1,7 +1,5 @@ import { DBLocalFolder, LocalFolderType } from '../DbTypes/DBLocalFolder'; -export interface LocalFolderItem {} - export class LocalFolder { private dbLocalFolder_: DBLocalFolder; diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets index 2989b66..a63fa92 100644 --- a/nextcloud/src/main/ets/Index.ets +++ b/nextcloud/src/main/ets/Index.ets @@ -1,6 +1,9 @@ /** * Nextcloud module entry point */ +export { LocalFolder } from './Database/Types/LocalFolder'; + +export { RemoteFolder } from './Database/Types/RemoteFolder'; export { SyncConfiguration } from './Database/Types/SyncConfiguration'; diff --git a/nextcloud/src/main/ets/SyncEngine/Category.ets b/nextcloud/src/main/ets/SyncEngine/Category.ets index 755ad95..d893b71 100644 --- a/nextcloud/src/main/ets/SyncEngine/Category.ets +++ b/nextcloud/src/main/ets/SyncEngine/Category.ets @@ -1,12 +1,16 @@ import { ImageListDataSource } from './ImageListDataSource'; -import { PhotoItem } from './PhotoItem'; +import { PhotoItem, UploadStatus } from './PhotoItem'; import { LocalFolder } from '../Database/Types/LocalFolder'; import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; import { PhotoAssetsManager } from './PhotoAssetsManager'; import { Logger } from '../Utils/Logger'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { CreateSession, IsRemotePathExists } from '../webdav/TransferUtils'; +import { UploadTransferJob } from './Jobs/UploadTransferJob'; const TAG = 'models/Category'; + export enum AddAssetResult { Added, Unmatched, @@ -66,11 +70,17 @@ export class Category { } } - async loadNextBatch(): Promise { + async loadNextBatch(): Promise { const offset = this.visibleItems.totalCount(); - let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, BATCH_SIZE); - for (let item of items) { - this.visibleItems.addAsset(item); + try { + let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, BATCH_SIZE); + for (let item of items) { + this.visibleItems.addAsset(item); + } + return items.length; + } catch (error) { + Logger.Error(TAG, `loadNextBatch: failed: ${JSON.stringify(error)}`); + return 0; } } @@ -78,41 +88,57 @@ export class Category { // getTotalAvailableCount(): number { // return this.visibleItems.totalCount() + this.invisibleBackedItems.size; // } - // - // async uploadAllAssets(notify: boolean = true) { - // const BATCH_SIZE = 8; - // // const totalCount = this.getTotalAvailableCount(); - // // let total = Math.ceil(totalCount / BATCH_SIZE) * BATCH_SIZE; - // let totalUploaded = 0; - // for (let i = 0; i < total; i += BATCH_SIZE) { - // let batch = this.getAssets(i, BATCH_SIZE); - // - // let canStop = true; - // // - // // let futures = batch.map(asset => { - // // return asset.upload(NCommon.Preference.baseUploadDirectory + this.location).then((result) => { - // // canStop = canStop && result === UploadResult.EXISTED; - // // totalUploaded++; - // // }).then(() => { - // // if (notify) { - // // // NotificationManager.publishNotification('title', 'content', 1); - // // } - // // }); - // // }); - // - // // await Promise.all(futures); - // // if (canStop) { - // // if (notify) { - // // // NotificationManager.publishNotification('title', 'content', 1); - // // } - // // promptAction.showToast({ - // // message: `Upload is probably completed`, - // // duration: 2000, - // // }); - // // break; - // // } - // } - // } + + public async uploadAllAssets(remoteFolder: RemoteFolder): Promise { + const BATCH_SIZE = 20; + Logger.Info(TAG, `uploadAllAssets: enter`); + + let jobs: Promise[] = []; + let totalCount = this.visibleItems.totalCount(); + let i = 0; + let continuesExistance = 0; + while (true) { + // to the current end + if (i === totalCount) { + const nextBatchSize = await this.loadNextBatch(); + if (nextBatchSize === 0) { + Logger.Info(TAG, `uploadAllAssets: reached to the end`); + break; + } + Logger.Info(TAG, `uploadAllAssets: continue from ${i} to ${totalCount}+${nextBatchSize}`); + totalCount = this.visibleItems.totalCount(); + } + Logger.Info(TAG, `uploadAllAssets: checking for ${i}/${totalCount} photo item`); + const photoItem = this.visibleItems.getData(i++); + photoItem.uploadStatus = UploadStatus.Pending; + const uploadData = photoItem.getUploadJobData(this.name, remoteFolder); + const fullRemotePath = '/' + remoteFolder.remotePath + '/' + uploadData.relativePath + '/' + uploadData.fileName; + + let exists: boolean; + { + const rcpSession = CreateSession(remoteFolder.account); + exists = await IsRemotePathExists(rcpSession, remoteFolder.account, fullRemotePath); + rcpSession.close(); + } + Logger.Info(TAG, `uploadAllAssets: relative path '${fullRemotePath}' exists: ${exists}`); + + if (exists) { + continuesExistance++; + photoItem.uploadStatus = UploadStatus.Existed; + if (continuesExistance === BATCH_SIZE) { + Logger.Info(TAG, `uploadAllAssets: last ${BATCH_SIZE} exists, complete`); + break; + } + } else { + continuesExistance = 0; + photoItem.uploadStatus = UploadStatus.Pending; + let job = photoItem.getUploadJob(this.name, remoteFolder); + jobs.push(job.start()); + } + } + + await Promise.all(jobs); + } private matchesAsset(asset: PhotoItem): boolean { return asset.ownerAlbumId === this.localFolder.albumId; diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets index 5c04676..6b48b31 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets @@ -2,11 +2,13 @@ import { RemoteFolder } from '../../Database/Types/RemoteFolder'; import { Logger } from '../../Utils/Logger'; import { CreateSession } from '../../webdav/TransferUtils'; import { - publishTransferEvent, TransferManagerEventCode, TransferType + publishTransferEvent, + TransferManagerEventCode, + TransferType, } from '../TransferManagerEventIPC'; import { BaseJob, JobData } from './BaseJob'; import { rcp } from '@kit.RemoteCommunicationKit'; - +// export interface TransferJobData extends JobData { fileName: string; @@ -19,8 +21,8 @@ export interface TransferJobData extends JobData { @ObservedV2 export class TransferJob extends BaseJob { readonly id: number; - @Trace public readonly type: TransferType; - @Trace public readonly filename: string; + public readonly type: TransferType; + public readonly filename: string; @Trace public totalSize: number = 1; @Trace public transferredSize: number = 0; @Trace public remoteFolder: RemoteFolder; diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets index c039bbb..358880d 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -10,6 +10,8 @@ import { Logger } from '../Utils/Logger'; const TAG = 'Utils/PhotoAssetsManager'; +const OWNER_ALBUM_ID = 'owner_album_id'; // API22 + @ObservedV2 class PhotoAssetsManagerImpl { private static readonly FETCH_COLUMNS = [ @@ -27,7 +29,7 @@ class PhotoAssetsManagerImpl { photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, photoAccessHelper.PhotoKeys.DETAIL_TIME, photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, - photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, + OWNER_ALBUM_ID, ]; @Trace public categories: Map = new Map(); @@ -39,7 +41,7 @@ class PhotoAssetsManagerImpl { const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, predicates: new dataSharePredicates.DataSharePredicates() - .equalTo(photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, albumId.toString()) + .equalTo(OWNER_ALBUM_ID, albumId.toString()) .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) .limit(limit, offset) }; @@ -109,7 +111,7 @@ class PhotoAssetsManagerImpl { photoAccessHelper.PhotoKeys.URI, photoAccessHelper.PhotoKeys.DATE_TAKEN, photoAccessHelper.PhotoKeys.DISPLAY_NAME, - photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID, + OWNER_ALBUM_ID, ], predicates: new dataSharePredicates.DataSharePredicates() .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) @@ -120,7 +122,7 @@ class PhotoAssetsManagerImpl { let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); while (!result.isAfterLast()) { const photoItem = await result.getNextObject(); - const albumId = photoItem.get(photoAccessHelper.PhotoKeys.OWNER_ALBUM_ID) as number; + const albumId = photoItem.get(OWNER_ALBUM_ID) as number; if (!albums.includes(albumId)) { albums.push(albumId); const displayName = photoItem.get(photoAccessHelper.PhotoKeys.DISPLAY_NAME); @@ -140,11 +142,15 @@ class PhotoAssetsManagerImpl { if (category) { category.tryAddAsset(photoItem, addPosition); - let account = Nextcloud.getAllAccounts()[0]; - let remoteFolderId = account.dbAccount.config.instantUploadFolderId; - let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); - Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); - photoItem.getUploadJob(category.name, remoteFolder!).start(); + if (category.localFolder.dbLocalFolder.config.instantUploadEnabled) { + let account = Nextcloud.getAllAccounts()[0]; + let remoteFolderId = account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + photoItem.getUploadJob(category.name, remoteFolder!).start(); + } else { + Logger.Warn(TAG, `instant upload not enabled for category ${category.name}, skipping ${photoItem.displayName}`); + } } else { Logger.Error(TAG, `add new category for ${ownerAlbumId} failed`); } diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets index 3602fef..097897e 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets @@ -4,7 +4,7 @@ import { image } from '@kit.ImageKit'; import { FormatTime } from '../Utils/CommonUtils'; import { RemoteFolder } from '../Database/Types/RemoteFolder'; import { BaseJob, JobState } from './Jobs/BaseJob'; -import { UploadTransferJob } from './Jobs/UploadTransferJob'; +import { UploadJobData, UploadTransferJob } from './Jobs/UploadTransferJob'; import { Logger } from '../Utils/Logger'; const TAG = 'PhotoItem'; @@ -70,19 +70,23 @@ export class PhotoItem { return await this.innerAsset.getThumbnail(size); } + public getUploadJobData(categoryName: string, remoteFolder: RemoteFolder): UploadJobData { + return { + fileName: this.displayName, + mTime: this.dateTakenMs, + uri: this.uri, + remoteFolder: remoteFolder, + relativePath: categoryName + '/' + FormatTime(this.dateTaken, DEFAULT_FORMAT) + '/', + }; + } + public getUploadJob(categoryName: string, remoteFolder: RemoteFolder): UploadTransferJob { Logger.Info(TAG, `getUploadJob: enter`); if (this.uploadJob) { return this.uploadJob; } - this.uploadJob = JobManager.createUploadJob({ - fileName: this.displayName, - mTime: this.dateTakenMs, - uri: this.uri, - remoteFolder: remoteFolder, - relativePath: categoryName + '/' + FormatTime(this.dateTaken, DEFAULT_FORMAT) + '/', - }); + this.uploadJob = JobManager.createUploadJob(this.getUploadJobData(categoryName, remoteFolder)); this.uploadJob.addStateListener((baseJob: BaseJob, state: JobState) => { switch (state) { diff --git a/nextcloud/src/main/ets/Utils/BasicDatasource.ets b/nextcloud/src/main/ets/Utils/BasicDatasource.ets index 3c0db22..ad291b7 100644 --- a/nextcloud/src/main/ets/Utils/BasicDatasource.ets +++ b/nextcloud/src/main/ets/Utils/BasicDatasource.ets @@ -26,6 +26,11 @@ export class BasicDataSource implements IDataSource { this.notifyDataAdd(this.entries.length - 1); } + public clear() { + this.entries.length = 0; + this.notifyDataReload(); + } + registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); From 9bdca6a6e227eeb22f16577c7ace75793d60edab Mon Sep 17 00:00:00 2001 From: Moody Date: Sun, 1 Feb 2026 01:13:34 +0800 Subject: [PATCH 14/16] upload page cleanup & settings update --- entry/patch.json | 2 +- entry/src/main/ets/pages/Index.ets | 22 +- entry/src/main/ets/pages/SettingsPage.ets | 61 ++++- entry/src/main/ets/pages/UploadPage.ets | 256 +++++++++--------- .../ets/Database/DbTypes/DBLocalFolder.ets | 21 +- nextcloud/src/main/ets/Index.ets | 2 + .../src/main/ets/SyncEngine/Category.ets | 14 +- .../main/ets/SyncEngine/SyncFolderManager.ets | 8 +- 8 files changed, 222 insertions(+), 164 deletions(-) diff --git a/entry/patch.json b/entry/patch.json index 7c64dd8..e2c3c61 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2001276, + "patchVersionCode": 2001296, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 6b18c7f..57a50c1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -92,17 +92,6 @@ struct Index { build() { Tabs({ barPosition: BarPosition.End }) { - - TabContent() { - UploadPage({ account: this.account }) - .width('100%') - .height('100%') - } - .tabBar(SubTabBarStyle.of($r('app.string.tab_upload')) - .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) - .board({ borderRadius: 0 }) - ) - TabContent() { Flex({ direction: FlexDirection.Column }) { Navigation(this.pathStack) { @@ -117,6 +106,17 @@ struct Index { .board({ borderRadius: 0 }) ) + + TabContent() { + UploadPage({ account: this.account }) + .width('100%') + .height('100%') + } + .tabBar(SubTabBarStyle.of($r('app.string.tab_upload')) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) + TabContent() { TransferProgress() .width('100%') diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index d21fd89..b9a284a 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -1,4 +1,7 @@ -import { Account, Logger, NCommon, Nextcloud } from 'nextcloud'; +import { + Account, Logger, NCommon, Nextcloud, LocalFolder, LocalFolderType, Category, + PhotoAssetsManager +} from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; import application from '@ohos.app.ability.application'; import { ShowToast } from '../utils/Utils'; @@ -14,7 +17,15 @@ export struct SettingsPage { @State private uploadReplaceExisting: boolean = false; @State private uploadBaseDirectory: string = '/'; + private categories: Category[] = []; aboutToAppear(): void { + this.categories = Array.from(PhotoAssetsManager.categories.values()); + this.categories.sort((a: Category, b: Category): number => { + if (a.instantUploadEnabled !== b.instantUploadEnabled) { + return a.instantUploadEnabled ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) this.doLoadSettings(); } @@ -29,11 +40,12 @@ export struct SettingsPage { Scroll(this.scroller) { Flex({ direction: FlexDirection.Column }) { - Column({ space: 4 }) { + Column() { Text($r('app.string.login_info')) .width('100%') .fontSize($r('sys.float.subheader_title_font_size')) .fontWeight(FontWeight.Bold) + .margin({ bottom: 4 }) Row() { Text($r('app.string.username')) @@ -53,15 +65,19 @@ export struct SettingsPage { .width('100%') .height(24) } + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) + .padding(12) Divider() .margin({ top: 16, bottom: 16 }) - Column({ space: 4 }) { + Column() { Text($r('app.string.upload')) .width('100%') .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) + .margin({ bottom: 4 }) Row() { Text($r('app.string.upload_base_directory')) @@ -89,18 +105,52 @@ export struct SettingsPage { .width('100%') .height(24) } + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) + .padding(12) Divider() .margin({ top: 16, bottom: 16 }) - Column({ space: 4 }) { + Column({ space: 8 }) { Text($r('app.string.categories')) .width('100%') .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) + .margin({ bottom: 4 }) + + Repeat(this.categories).each((item: RepeatItem) => { + Row() { + Column() { + Text(item.item.name) + Text(`Album ${item.item.localFolder.albumId}`) + } + .alignItems(HorizontalAlign.Start) + + Row({ space: 8 }) { + Text('Upload') + Toggle({ type: ToggleType.Switch, isOn: item.item.instantUploadEnabled }) + .onChange((value: boolean) => { + item.item.instantUploadEnabled = value; + Nextcloud.getSyncFolderManager().updateLocalFolder(item.item.localFolder); + }) + .height('60px') + .width('107px') + } + .alignItems(VerticalAlign.Center) + } + .justifyContent(FlexAlign.SpaceBetween) + .width('100%') + .backgroundColor($r('sys.color.gray_03')) + .borderRadius(8) + .padding(12) + }) } + .padding(12) + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) } - .margin({ left: 24, right: 24 }) + .margin({ left: 16, right: 16 }) } .align(Alignment.TopStart) .edgeEffect(EdgeEffect.Spring) @@ -114,6 +164,7 @@ export struct SettingsPage { .margin({ top: 16, bottom: 16 }) Button('DROP DATABASE') + .visibility(Visibility.None) .backgroundColor(Color.Red) .onClick(async () => { await Nextcloud.recreateDatabase(); diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index 3899cf5..9f543ca 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -90,137 +90,138 @@ struct CategoryView { @Require @Param category: Category; @Local private isEditing: boolean = false; @Local private isExpanded: boolean = false; - @Local private isSyncEnabled: boolean = false; + @Local private viewHeight: number = 56; aboutToAppear(): void { - if (this.category.instantUploadEnabled) { - this.isSyncEnabled = true; - } } - build() { - Column({ space: 8 }) { - Row({ space: 16 }) { - Row({ space: 8 }) { - if (this.isEditing) { - TextInput({ text: this.category.name }) - .fontWeight(FontWeight.Bold) - .flexShrink(1) - .fontSize(16) - .borderRadius(0) - .onSubmit(async (enterKey: EnterKeyType, event: SubmitEvent) => { - if (event.text.length === 0) { - this.getUIContext() - .getPromptAction() - .showDialog({ - title: `Category Rename`, - message: `Category name cannot be empty!`, - }) - .catch((e: Error) => { - Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); - }); + @Builder + topBar() { + Row({ space: 16 }) { + Row({ space: 8 }) { + if (this.isEditing) { + TextInput({ text: this.category.name }) + .fontWeight(FontWeight.Bold) + .flexShrink(1) + .fontSize(16) + .borderRadius(0) + .onSubmit(async (enterKey: EnterKeyType, event: SubmitEvent) => { + if (event.text.length === 0) { + this.getUIContext() + .getPromptAction() + .showDialog({ + title: `Category Rename`, + message: `Category name cannot be empty!`, + }) + .catch((e: Error) => { + Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); + }); - event.keepEditableState(); - return; - } - Logger.Info(TAG, `category edit onSubmit: ${EnterKeyType[enterKey]}`) - this.category.setAlias(event.text); - Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); - }) - .onBlur(() => { - this.isEditing = false; - }) - } else { - Text(this.category.name) - .fontSize(24) + event.keepEditableState(); + return; + } + Logger.Info(TAG, `category edit onSubmit: ${EnterKeyType[enterKey]}`) + this.category.setAlias(event.text); + Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); + }) + .onBlur(() => { + this.isEditing = false; + }) + } else { + Text(this.category.name) + .fontSize(24) - SymbolGlyph($r('sys.symbol.doc_plaintext_and_pencil')) - .fontSize(12) - .fontColor([Color.Gray]) - .onClick(() => { - this.isEditing = true; - }) - } + SymbolGlyph($r('sys.symbol.doc_plaintext_and_pencil')) + .fontSize(12) + .fontColor([Color.Gray]) + .onClick(() => { + this.isEditing = true; + }) } - .flexShrink(1) - .height(36) - - Row({ space: 16 }) { - Row() { - Checkbox() - .select(this.isSyncEnabled) - .onChange((value: boolean) => { - this.toggleSyncEnable(value); - }) - Text(`Upload`) - } + } + .flexShrink(1) + .height(36) - Button() { - SymbolGlyph(this.isExpanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) - .fontSize(24) - .fontColor([$r('sys.color.font')]) - } - .onClick(() => { - this.isExpanded = !this.isExpanded; - }) - .padding(8) - .type(ButtonType.Circle) - .buttonStyle(ButtonStyleMode.TEXTUAL) - .backgroundColor($r('sys.color.gray_03')) + Row({ space: 16 }) { + Button() { + SymbolGlyph(this.isExpanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(24) + .fontColor([$r('sys.color.font')]) } + .onClick(() => { + this.isExpanded = !this.isExpanded; + this.viewHeight = this.isExpanded ? 382 : 56; + }) + .padding(8) + .type(ButtonType.Circle) + .buttonStyle(ButtonStyleMode.TEXTUAL) + .backgroundColor($r('sys.color.gray_03')) } - .justifyContent(FlexAlign.SpaceBetween) - .width('100%') - - if (this.isExpanded) { - Blank() - .height(6) + } + .onClick(() => { + this.isExpanded = !this.isExpanded; + this.viewHeight = this.isExpanded ? 382 : 56; + }) + .justifyContent(FlexAlign.SpaceBetween) + .width('100%') + } - Stack() { - Grid() { - LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { - GridItem() { - PhotoView({ asset: asset, category: this.category, account: this.account }) - .enabled(this.isSyncEnabled) - } - .width('100%') - .onAppear(() => { - if (index == this.category.visibleItems.totalCount() - 1) { - this.category.loadNextBatch(); - } - }) - }) + @Builder + photoGrid() { + Grid() { + LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { + GridItem() { + PhotoView({ asset: asset, category: this.category, account: this.account }) + .enabled(this.category.instantUploadEnabled) + } + .width('100%') + .onAppear(() => { + if (index == this.category.visibleItems.totalCount() - 1) { + this.category.loadNextBatch(); } - .layoutDirection(GridDirection.Row) - .fadingEdge(true) - .columnsTemplate(`repeat(auto-fit, ${PHOTO_PREVIEW_SIZE})`) - .scrollBar(BarState.On) - .edgeEffect(EdgeEffect.Spring) - .columnsGap(8) - .rowsGap(8) - .cachedCount(10) - .width('100%') - .height('100%') - .margin({ bottom: 12 }) + }) + }) + } + .layoutDirection(GridDirection.Row) + .fadingEdge(true) + .columnsTemplate(`repeat(auto-fit, ${PHOTO_PREVIEW_SIZE})`) + .scrollBar(BarState.On) + .edgeEffect(EdgeEffect.Spring) + .columnsGap(8) + .rowsGap(8) + .cachedCount(10) + .width('100%') + .height('100%') + .margin({ bottom: 12 }) + } - Column() { - Button($r('app.string.upload_all')) - .onClick(async () => { - Logger.Info(TAG, `Upload all in '${this.category.name}'`); - const remoteFolder = await getUploadRemoteFolder(this.account); - this.category.uploadAllAssets(remoteFolder); - }) - .backgroundColor(this.isSyncEnabled ? '#0082C9' : 'gray') - .width('30%') - } - .enabled(this.isSyncEnabled) - .width('100%') - .position({ bottom: 10 }) + build() { + Column({ space: 8 }) { + // Top Bar Row + this.topBar() + + Blank() + .height(6) + + Stack() { + this.photoGrid(); + + Column() { + Button($r('app.string.upload_all')) + .onClick(async () => { + const remoteFolder = await getUploadRemoteFolder(this.account); + this.category.uploadAllAssets(remoteFolder); + }) + .backgroundColor(this.category.instantUploadEnabled ? '#0082C9' : 'gray') + .width('30%') } + .enabled(this.category.instantUploadEnabled) .width('100%') - .height('100%') - .constraintSize({ maxHeight: 300, minHeight: 100 }) + .position({ bottom: 10 }) } + .width('100%') + .height('100%') + .constraintSize({ maxHeight: 300, minHeight: 100 }) } .borderRadius(16) .padding({ @@ -231,19 +232,14 @@ struct CategoryView { }) .backgroundColor($r('sys.color.gray_02')) .width('100%') - .height('auto') - } - - private async toggleSyncEnable(enabled: boolean) { - Logger.Info(TAG, `setting category ${this.category.name} sync enable to ${enabled}`); - this.isSyncEnabled = enabled; - this.category.localFolder.dbLocalFolder.config.instantUploadEnabled = this.isSyncEnabled; - await Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); - Logger.Info(TAG, `done`); - } - - private async uploadAllAssets() { - + .height(this.viewHeight) + .renderFit(RenderFit.CENTER) + .clip(true) + .animation({ + duration: 250, + curve: Curve.Friction, + playMode: PlayMode.Normal, + }) } } @@ -269,7 +265,9 @@ export struct UploadPage { List({ space: 10 }) { Repeat(this.categories) .each((item: RepeatItem) => { - CategoryView({ category: item.item, account: this.account }) + if (item.item.instantUploadEnabled) { + CategoryView({ category: item.item, account: this.account }) + } }) } .width('100%') diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets index 730abfb..06d3f9a 100644 --- a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets +++ b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets @@ -9,8 +9,21 @@ export enum LocalFolderType { PhotoAlbum, } -export interface DBLocalFolderConfig { - instantUploadEnabled: boolean; +@ObservedV2 +export class DBLocalFolderConfig { + @Trace instantUploadEnabled: boolean = false; + + constructor(record: Record) { + this.instantUploadEnabled = record['instantUploadEnabled'] as boolean ?? false; + } + + public toJson() { + let object: Record = { + 'instantUploadEnabled': this.instantUploadEnabled, + }; + + return JSON.stringify(object); + } } @ObservedV2 @@ -51,7 +64,7 @@ export class DBLocalFolder implements DBItem { bucket['local_path'] = this.localPath; bucket['alias'] = this.alias; bucket['folder_type'] = this.folderType; - bucket['config'] = JSON.stringify(this.config); + bucket['config'] = this.config.toJson(); return bucket; } @@ -77,7 +90,7 @@ export const DBLocalFolderTable: DBTableDescriptor { - const BATCH_SIZE = 20; + const BATCH_SIZE = 16; Logger.Info(TAG, `uploadAllAssets: enter`); let jobs: Promise[] = []; diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets index 9ab777c..1a64804 100644 --- a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -42,9 +42,7 @@ export class SyncFolderManager { public async addLocalAlbumFolder(albumId: number, alias: string): Promise { Logger.Info(TAG, `addLocalAlbumFolder: enter`); - let config: DBLocalFolderConfig = { - instantUploadEnabled: false - }; + let config = new DBLocalFolderConfig({}); let dbLocalFolder = new DBLocalFolder(0, albumId.toString(), alias, LocalFolderType.PhotoAlbum, config); let localFolder = new LocalFolder(dbLocalFolder); let insertedId = await this.dbManager_.createItem(dbLocalFolder); @@ -56,9 +54,7 @@ export class SyncFolderManager { public async addLocalFolder(localPath: string, alias: string): Promise { Logger.Info(TAG, `addLocalFolder: enter`); - let config: DBLocalFolderConfig = { - instantUploadEnabled: false - }; + let config = new DBLocalFolderConfig({}) let dbLocalFolder = new DBLocalFolder(0, localPath, alias, LocalFolderType.Regular, config); let localFolder = new LocalFolder(dbLocalFolder); let insertedId = await this.dbManager_.createItem(dbLocalFolder); From 29e50336fec8ebcf4993914f1403a94c85c0dc55 Mon Sep 17 00:00:00 2001 From: Moody Date: Tue, 7 Apr 2026 23:49:30 +0800 Subject: [PATCH 15/16] temp --- .../src/main/ets/SyncEngine/Category.ets | 4 +- .../ets/SyncEngine/Jobs/UploadTransferJob.ets | 124 +++++++++++------- 2 files changed, 75 insertions(+), 53 deletions(-) diff --git a/nextcloud/src/main/ets/SyncEngine/Category.ets b/nextcloud/src/main/ets/SyncEngine/Category.ets index 6da361f..68fedf4 100644 --- a/nextcloud/src/main/ets/SyncEngine/Category.ets +++ b/nextcloud/src/main/ets/SyncEngine/Category.ets @@ -131,11 +131,9 @@ export class Category { continuesExistance = 0; photoItem.uploadStatus = UploadStatus.Pending; let job = photoItem.getUploadJob(this.name, remoteFolder); - jobs.push(job.start()); + await job.start(); } } - - await Promise.all(jobs); } private matchesAsset(asset: PhotoItem): boolean { diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets index ebd7ed3..2d58cb9 100644 --- a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets @@ -5,7 +5,6 @@ import { Logger } from '../../Utils/Logger'; import { rcp } from '@kit.RemoteCommunicationKit'; import { TransferType } from '../TransferManagerEventIPC'; import { NCommon } from '../../Common'; - export interface UploadCallback { jobCreated, aboutToUpload, @@ -82,63 +81,88 @@ export class UploadTransferJob extends TransferJob { } } - const tempDirectory = NCommon.RandomString(16); - const chunkedUploadRemoteDir = account.chunkedUploadBaseUrl() + tempDirectory + `/`; - session.configuration!.headers!['Destination'] = account.remoteBaseDirUrl() + destFile; + if (this.fileStat.size <= DEFAULT_CHUNK_SIZE) { + Logger.Info(this.TAG, `begin regular upload`); - try { - let resp = await session.fetch(new rcp.Request(chunkedUploadRemoteDir, "MKCOL", {})); - Logger.Info(this.TAG, `Created chunked upload temp directory '${tempDirectory}' with response: ${resp.statusCode}`); - if (resp.statusCode !== 201) { - throw new Error(`Failed to create chunked upload temp directory, status code: ${resp.statusCode}`); - } - } catch (e) { - Logger.Error(this.TAG, `failed to create chunked upload temp directory '${tempDirectory}': ${e}`); - await session.delete(chunkedUploadRemoteDir); - await fileIo.close(this.file.fd); - throw e as Error; - } + try { + let arrayBuffer = new ArrayBuffer(this.fileStat.size); + let readLen = await fileIo.read(this.file.fd, arrayBuffer, { length: this.fileStat.size }); - let remainingSize = this.fileStat.size; - session.configuration!.headers!['OC-Total-Length'] = `${this.fileStat.size}`; - try { - let fileCount = 0; - for (let uploadedSize = 0; uploadedSize < this.fileStat.size; ) { - let chunkSize = Math.min(remainingSize, DEFAULT_CHUNK_SIZE); - let arrayBuffer = new ArrayBuffer(chunkSize); - let readLen = await fileIo.read(this.file.fd, arrayBuffer, { - length: chunkSize, - offset: uploadedSize - }); - - let chunkPath = chunkedUploadRemoteDir + fileCount.toString().padStart(5, '0'); - Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for chunked upload to ${chunkPath}`); + let remotePath = account.remoteBaseDirUrl() + destFile; + Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for upload to ${remotePath}`); - let resp = await session.put(chunkPath, arrayBuffer); - Logger.Info(this.TAG, `Chunk upload response for ${chunkPath}: ${resp.statusCode}`); + let resp = await session.put(remotePath, arrayBuffer); + Logger.Info(this.TAG, `upload response for ${remotePath}: ${resp.statusCode}`); if (resp.statusCode !== 201 && resp.statusCode !== 204) { - throw new Error(`Chunk upload failed with status code: ${resp.statusCode}`); + throw new Error(`upload failed with status code: ${resp.statusCode}`); } - - uploadedSize += readLen; - remainingSize -= readLen; - fileCount++; - this.historicalBatchesTransferredSize = uploadedSize; + this.uploadStatusCode = resp.statusCode; } - // Finalize chunked upload - let moveRequest = new rcp.Request(chunkedUploadRemoteDir + '.file', "MOVE", {}); - let resp = await session.fetch(moveRequest); - Logger.Info(this.TAG, `Finalized chunked upload with response: ${resp.statusCode}`); - if (resp.statusCode !== 201 && resp.statusCode !== 204) { - throw new Error(`Finalizing chunked upload failed with status code: ${resp.statusCode}`); + catch (e) { + Logger.Error(this.TAG, `upload failed: ${JSON.stringify(e)}`); + throw e as Error; + } + } else { + Logger.Info(this.TAG, `begin chunked upload`); + const tempDirectory = NCommon.RandomString(16); + const chunkedUploadRemoteDir = account.chunkedUploadBaseUrl() + tempDirectory + `/`; + session.configuration!.headers!['Destination'] = account.remoteBaseDirUrl() + destFile; + + try { + let resp = await session.fetch(new rcp.Request(chunkedUploadRemoteDir, "MKCOL", {})); + Logger.Info(this.TAG, `Created chunked upload temp directory '${tempDirectory}' with response: ${resp.statusCode}`); + if (resp.statusCode !== 201) { + throw new Error(`Failed to create chunked upload temp directory, status code: ${resp.statusCode}`); + } + } catch (e) { + Logger.Error(this.TAG, `failed to create chunked upload temp directory '${tempDirectory}': ${e}`); + await session.delete(chunkedUploadRemoteDir); + await fileIo.close(this.file.fd); + throw e as Error; + } + + let remainingSize = this.fileStat.size; + session.configuration!.headers!['OC-Total-Length'] = `${this.fileStat.size}`; + try { + let fileCount = 0; + for (let uploadedSize = 0; uploadedSize < this.fileStat.size;) { + let chunkSize = Math.min(remainingSize, DEFAULT_CHUNK_SIZE); + let arrayBuffer = new ArrayBuffer(chunkSize); + let readLen = await fileIo.read(this.file.fd, arrayBuffer, { + length: chunkSize, + offset: uploadedSize + }); + + let chunkPath = chunkedUploadRemoteDir + fileCount.toString().padStart(5, '0'); + Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for chunked upload to ${chunkPath}`); + + let resp = await session.put(chunkPath, arrayBuffer); + Logger.Info(this.TAG, `Chunk upload response for ${chunkPath}: ${resp.statusCode}`); + + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Chunk upload failed with status code: ${resp.statusCode}`); + } + + uploadedSize += readLen; + remainingSize -= readLen; + fileCount++; + this.historicalBatchesTransferredSize = uploadedSize; + } + // Finalize chunked upload + let moveRequest = new rcp.Request(chunkedUploadRemoteDir + '.file', "MOVE", {}); + let resp = await session.fetch(moveRequest); + Logger.Info(this.TAG, `Finalized chunked upload with response: ${resp.statusCode}`); + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Finalizing chunked upload failed with status code: ${resp.statusCode}`); + } + this.uploadStatusCode = resp.statusCode; + } catch (e) { + await session.delete(chunkedUploadRemoteDir); + Logger.Error(this.TAG, `chunked upload failed: ${e}`); + } finally { + await fileIo.close(this.file.fd); } - this.uploadStatusCode = resp.statusCode; - } catch (e) { - await session.delete(chunkedUploadRemoteDir); - Logger.Error(this.TAG, `chunked upload failed: ${e}`); - } finally { - await fileIo.close(this.file.fd); } } } From 625f85d29d794d2e733a92bacf48c33699582178 Mon Sep 17 00:00:00 2001 From: Moody Liu Date: Sat, 4 Jul 2026 01:37:09 +0800 Subject: [PATCH 16/16] one can finally get album name --- entry/src/main/ets/pages/SettingsPage.ets | 56 ++++++++++--------- .../ets/SyncEngine/PhotoAssetsManager.ets | 34 +++++++++-- 2 files changed, 60 insertions(+), 30 deletions(-) diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index b9a284a..24d252e 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -119,32 +119,35 @@ export struct SettingsPage { .fontSize($r('sys.float.subheader_title_font_size')) .margin({ bottom: 4 }) - Repeat(this.categories).each((item: RepeatItem) => { - Row() { - Column() { - Text(item.item.name) - Text(`Album ${item.item.localFolder.albumId}`) + Repeat(this.categories) + .each((item: RepeatItem) => { + Row() { + Column() { + Text(item.item.name) + Text(`Album Id: ${item.item.localFolder.albumId}`) + .fontSize(10) + } + .alignItems(HorizontalAlign.Start) + + Row({ space: 8 }) { + Text('Upload') + Toggle({ type: ToggleType.Switch, isOn: item.item.instantUploadEnabled }) + .onChange((value: boolean) => { + item.item.instantUploadEnabled = value; + Nextcloud.getSyncFolderManager() + .updateLocalFolder(item.item.localFolder); + }) + .height('60px') + .width('107px') + } + .alignItems(VerticalAlign.Center) } - .alignItems(HorizontalAlign.Start) - - Row({ space: 8 }) { - Text('Upload') - Toggle({ type: ToggleType.Switch, isOn: item.item.instantUploadEnabled }) - .onChange((value: boolean) => { - item.item.instantUploadEnabled = value; - Nextcloud.getSyncFolderManager().updateLocalFolder(item.item.localFolder); - }) - .height('60px') - .width('107px') - } - .alignItems(VerticalAlign.Center) - } - .justifyContent(FlexAlign.SpaceBetween) - .width('100%') - .backgroundColor($r('sys.color.gray_03')) - .borderRadius(8) - .padding(12) - }) + .justifyContent(FlexAlign.SpaceBetween) + .width('100%') + .backgroundColor($r('sys.color.gray_03')) + .borderRadius(8) + .padding(12) + }) } .padding(12) .backgroundColor($r('sys.color.gray_02')) @@ -169,7 +172,8 @@ export struct SettingsPage { .onClick(async () => { await Nextcloud.recreateDatabase(); try { - await application.getApplicationContext().killAllProcesses(); + await application.getApplicationContext() + .killAllProcesses(); } catch (error) { Logger.Error(TAG, `failed to kill all processes: ${JSON.stringify(error)}`); } diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets index 358880d..8e97513 100644 --- a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -71,7 +71,15 @@ class PhotoAssetsManagerImpl { const sfm = Nextcloud.getSyncFolderManager(); const albums = await this.getAllCategories(); for (let albumId of albums) { - if (!this.categories.has(albumId)) { + if (this.categories.has(albumId)) { + const category = this.categories.get(albumId)!; + if (category.name === `Album ${albumId}`) { + const albumName = await this.getAlbumNameById(albumId); + Logger.Warn(TAG, `updating ${albumId} name --> ${albumName}`); + category.localFolder.alias = albumName; + sfm.updateLocalFolder(category.localFolder); + } + } else { Logger.Warn(TAG, `initialize: found new AlbumId: ${albumId}`); let localFolder = await sfm.addLocalAlbumFolder(albumId, `Album ${albumId}`); this.categories.set(localFolder.albumId, new Category(localFolder)); @@ -136,6 +144,21 @@ class PhotoAssetsManagerImpl { return albums; } + private async getAlbumNameById(albumId: number): Promise { + const ALBUM_URI_PREFIX = 'file://media/PhotoAlbum/'; + let result = await NCommon.pHelper.getAlbums(2048, 2049); + + const allAlbums = await result.getAllObjects(); + + for (const album of allAlbums) { + if (album.albumUri === (ALBUM_URI_PREFIX + albumId)) { + return album.albumName; + } + } + Logger.Warn(TAG, `getAlbumNameById: album id: ${albumId} not found!`); + return ''; + } + private async doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { let ownerAlbumId = photoItem.ownerAlbumId; let category = this.categories.get(ownerAlbumId); @@ -145,11 +168,14 @@ class PhotoAssetsManagerImpl { if (category.localFolder.dbLocalFolder.config.instantUploadEnabled) { let account = Nextcloud.getAllAccounts()[0]; let remoteFolderId = account.dbAccount.config.instantUploadFolderId; - let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + let remoteFolder = await Nextcloud.getSyncFolderManager() + .getRemoteFolderById(remoteFolderId); Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); - photoItem.getUploadJob(category.name, remoteFolder!).start(); + photoItem.getUploadJob(category.name, remoteFolder!) + .start(); } else { - Logger.Warn(TAG, `instant upload not enabled for category ${category.name}, skipping ${photoItem.displayName}`); + Logger.Warn(TAG, + `instant upload not enabled for category ${category.name}, skipping ${photoItem.displayName}`); } } else { Logger.Error(TAG, `add new category for ${ownerAlbumId} failed`);