mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
background upload plugin
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up
This commit is contained in:
@@ -32,7 +32,6 @@ target 'Runner' do
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
|
||||
# share_handler addition start
|
||||
target 'ShareExtension' do
|
||||
inherit! :search_paths
|
||||
|
||||
+13
-13
@@ -88,9 +88,9 @@ PODS:
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SAMKeychain (1.5.3)
|
||||
- SDWebImage (5.21.0):
|
||||
- SDWebImage/Core (= 5.21.0)
|
||||
- SDWebImage/Core (5.21.0)
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
- share_handler_ios (0.0.14):
|
||||
- Flutter
|
||||
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
||||
@@ -107,16 +107,16 @@ PODS:
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.49.1):
|
||||
- sqlite3/common (= 3.49.1)
|
||||
- sqlite3/common (3.49.1)
|
||||
- sqlite3/dbstatvtab (3.49.1):
|
||||
- sqlite3 (3.49.2):
|
||||
- sqlite3/common (= 3.49.2)
|
||||
- sqlite3/common (3.49.2)
|
||||
- sqlite3/dbstatvtab (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.49.1):
|
||||
- sqlite3/fts5 (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.49.1):
|
||||
- sqlite3/perf-threadsafe (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.49.1):
|
||||
- sqlite3/rtree (3.49.2):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
@@ -275,18 +275,18 @@ SPEC CHECKSUMS:
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
|
||||
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
|
||||
PODFILE CHECKSUM: 95621706d175fee669455a5946a602e2a775019c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -77,6 +77,16 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(EXTENSIONS_FOLDER_PATH)";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
);
|
||||
name = "Embed ExtensionKit Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -136,15 +146,11 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -156,10 +162,18 @@
|
||||
path = WidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FE14355D2EC446E90009D5AC /* Upload */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Upload;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -267,7 +281,9 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE14355D2EC446E90009D5AC /* Upload */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
@@ -345,6 +361,7 @@
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
|
||||
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
|
||||
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -355,6 +372,8 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FE14355D2EC446E90009D5AC /* Upload */,
|
||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
);
|
||||
name = Runner;
|
||||
@@ -407,7 +426,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1640;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
@@ -549,10 +568,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -581,10 +604,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -735,7 +762,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -744,7 +771,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile;
|
||||
PRODUCT_NAME = "Immich-Profile";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -879,7 +907,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -888,7 +916,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug;
|
||||
PRODUCT_NAME = "Immich-Debug";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -909,7 +938,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -918,7 +947,8 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.121.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -Xllvm -sil-disable-pass=performance-linker";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich;
|
||||
PRODUCT_NAME = Immich;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -942,7 +972,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -959,7 +989,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
@@ -985,7 +1015,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1001,7 +1031,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -1025,7 +1055,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1041,7 +1071,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile.Widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -1065,7 +1095,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1082,7 +1112,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1109,7 +1139,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1125,7 +1155,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1150,7 +1180,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
DEVELOPMENT_TEAM = 33MF3D8ZGA;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1166,7 +1196,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.mertakev.immich.profile.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -28,15 +28,6 @@
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -132,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
|
||||
"version" : "0.25.0"
|
||||
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
|
||||
"version" : "0.25.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -145,15 +136,6 @@
|
||||
"version" : "602.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-tagged",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||
"state" : {
|
||||
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||
"version" : "0.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
import UIKit
|
||||
import network_info_plus
|
||||
import path_provider_foundation
|
||||
import permission_handler_apple
|
||||
import photo_manager
|
||||
import shared_preferences_foundation
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -15,7 +15,7 @@ import UIKit
|
||||
) -> Bool {
|
||||
// Required for flutter_local_notification
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
@@ -36,7 +36,9 @@ import UIKit
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
||||
SharedPreferencesPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!
|
||||
)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
||||
@@ -50,13 +52,22 @@ import UIKit
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
|
||||
public static func registerPlugins(with engine: FlutterEngine) {
|
||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
|
||||
let statusListener = StatusEventListener()
|
||||
StreamStatusStreamHandler.register(with: engine.binaryMessenger, streamHandler: statusListener)
|
||||
let progressListener = ProgressEventListener()
|
||||
StreamProgressStreamHandler.register(with: engine.binaryMessenger, streamHandler: progressListener)
|
||||
UploadApiSetup.setUp(
|
||||
binaryMessenger: engine.binaryMessenger,
|
||||
api: UploadApiImpl(statusListener: statusListener, progressListener: progressListener)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
|
||||
}
|
||||
|
||||
@@ -350,16 +350,12 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
|
||||
|
||||
// If we have required Wi-Fi, we can check the isExpensive property
|
||||
let requireWifi = defaults.value(forKey: "require_wifi") as? Bool ?? false
|
||||
if (requireWifi) {
|
||||
let wifiMonitor = NWPathMonitor(requiredInterfaceType: .wifi)
|
||||
let isExpensive = wifiMonitor.currentPath.isExpensive
|
||||
if (isExpensive) {
|
||||
// The network is expensive and we have required Wi-Fi
|
||||
// Therefore, we will simply complete the task without
|
||||
// running it
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
// The network is expensive and we have required Wi-Fi
|
||||
// Therefore, we will simply complete the task without
|
||||
// running it
|
||||
if (requireWifi && NetworkMonitor.shared.isExpensive) {
|
||||
return task.setTaskCompleted(success: true)
|
||||
}
|
||||
|
||||
// Schedule the next sync task so we can run this again later
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
class ImmichPlugin: NSObject {
|
||||
var detached: Bool
|
||||
|
||||
|
||||
override init() {
|
||||
detached = false
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
||||
func detachFromEngine() {
|
||||
self.detached = true
|
||||
}
|
||||
|
||||
|
||||
func completeWhenActive<T>(for completion: @escaping (T) -> Void, with value: T) {
|
||||
guard !self.detached else { return }
|
||||
completion(value)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func dPrint(_ item: Any) {
|
||||
#if DEBUG
|
||||
print(item)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import SQLiteData
|
||||
|
||||
struct Endpoint: Codable {
|
||||
let url: URL
|
||||
let status: Status
|
||||
extension Notification.Name {
|
||||
static let networkDidConnect = Notification.Name("networkDidConnect")
|
||||
}
|
||||
|
||||
enum Status: String, Codable {
|
||||
case loading, valid, error, unknown
|
||||
}
|
||||
enum TaskConfig {
|
||||
static let maxActiveDownloads = 3
|
||||
static let maxPendingDownloads = 50
|
||||
static let maxPendingUploads = 50
|
||||
static let maxRetries = 10
|
||||
static let sessionId = "app.mertalev.immich.upload"
|
||||
static let downloadCheckIntervalNs: UInt64 = 30_000_000_000 // 30 seconds
|
||||
static let downloadTimeoutS = TimeInterval(60)
|
||||
static let transferSpeedAlpha = 0.4
|
||||
static let originalsDir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"originals",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
@@ -47,8 +57,6 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
static let deviceId = Typed<String>(rawValue: ._deviceId)
|
||||
case _accessToken = 11
|
||||
static let accessToken = Typed<String>(rawValue: ._accessToken)
|
||||
case _serverEndpoint = 12
|
||||
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
|
||||
case _sslClientCertData = 15
|
||||
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
|
||||
case _sslClientPasswd = 16
|
||||
@@ -67,10 +75,12 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
|
||||
|
||||
// MARK: - URL
|
||||
case _localEndpoint = 134
|
||||
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||
case _serverUrl = 10
|
||||
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
|
||||
case _serverEndpoint = 12
|
||||
static let serverEndpoint = Typed<URL>(rawValue: ._serverEndpoint)
|
||||
case _localEndpoint = 134
|
||||
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||
|
||||
// MARK: - Date
|
||||
case _backupFailedSince = 5
|
||||
@@ -160,6 +170,17 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||
}
|
||||
}
|
||||
|
||||
enum UploadHeaders: String {
|
||||
case reprDigest = "Repr-Digest"
|
||||
case userToken = "X-Immich-User-Token"
|
||||
case assetData = "X-Immich-Asset-Data"
|
||||
}
|
||||
|
||||
enum TaskStatus: Int, QueryBindable {
|
||||
case downloadPending, downloadQueued, downloadFailed, uploadPending, uploadQueued, uploadFailed, uploadComplete,
|
||||
uploadSkipped
|
||||
}
|
||||
|
||||
enum BackupSelection: Int, QueryBindable {
|
||||
case selected, none, excluded
|
||||
}
|
||||
@@ -175,3 +196,77 @@ enum AlbumUserRole: Int, QueryBindable {
|
||||
enum MemoryType: Int, QueryBindable {
|
||||
case onThisDay
|
||||
}
|
||||
|
||||
enum AssetVisibility: Int, QueryBindable {
|
||||
case timeline, hidden, archive, locked
|
||||
}
|
||||
|
||||
enum SourceType: String, QueryBindable {
|
||||
case machineLearning = "machine-learning"
|
||||
case exif, manual
|
||||
}
|
||||
|
||||
enum UploadMethod: Int, QueryBindable {
|
||||
case multipart, resumable
|
||||
}
|
||||
|
||||
enum UploadError: Error {
|
||||
case fileCreationFailed
|
||||
case iCloudError(UploadErrorCode)
|
||||
case photosError(UploadErrorCode)
|
||||
}
|
||||
|
||||
enum UploadErrorCode: Int, QueryBindable {
|
||||
case unknown
|
||||
case assetNotFound
|
||||
case fileNotFound
|
||||
case resourceNotFound
|
||||
case invalidResource
|
||||
case encodingFailed
|
||||
case writeFailed
|
||||
case notEnoughSpace
|
||||
case networkError
|
||||
case photosInternalError
|
||||
case photosUnknownError
|
||||
case noServerUrl
|
||||
case noDeviceId
|
||||
case noAccessToken
|
||||
case interrupted
|
||||
case cancelled
|
||||
case downloadStalled
|
||||
case forceQuit
|
||||
case outOfResources
|
||||
case backgroundUpdatesDisabled
|
||||
case uploadTimeout
|
||||
case iCloudRateLimit
|
||||
case iCloudThrottled
|
||||
case invalidResponse
|
||||
case badRequest
|
||||
case internalServerError
|
||||
}
|
||||
|
||||
enum AssetType: Int, QueryBindable {
|
||||
case other, image, video, audio
|
||||
}
|
||||
|
||||
enum AssetMediaStatus: String, Codable {
|
||||
case created, replaced, duplicate
|
||||
}
|
||||
|
||||
struct Endpoint: Codable {
|
||||
let url: URL
|
||||
let status: Status
|
||||
|
||||
enum Status: String, Codable {
|
||||
case loading, valid, error, unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadSuccessResponse: Codable {
|
||||
let status: AssetMediaStatus
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct UploadErrorResponse: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
@@ -4,35 +4,75 @@ enum StoreError: Error {
|
||||
case invalidJSON(String)
|
||||
case invalidURL(String)
|
||||
case encodingFailed
|
||||
case notFound
|
||||
}
|
||||
|
||||
protocol StoreConvertible {
|
||||
static var cacheKeyPath: ReferenceWritableKeyPath<StoreCache, [StoreKey: Self]> { get }
|
||||
associatedtype StorageType
|
||||
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||
}
|
||||
|
||||
extension StoreConvertible {
|
||||
static func get(_ cache: StoreCache, key: StoreKey) -> Self? {
|
||||
os_unfair_lock_lock(&cache.lock)
|
||||
defer { os_unfair_lock_unlock(&cache.lock) }
|
||||
return cache[keyPath: cacheKeyPath][key]
|
||||
}
|
||||
|
||||
static func set(_ cache: StoreCache, key: StoreKey, value: Self?) {
|
||||
os_unfair_lock_lock(&cache.lock)
|
||||
defer { os_unfair_lock_unlock(&cache.lock) }
|
||||
cache[keyPath: cacheKeyPath][key] = value
|
||||
}
|
||||
}
|
||||
|
||||
final class StoreCache {
|
||||
fileprivate var lock = os_unfair_lock()
|
||||
fileprivate var intCache: [StoreKey: Int] = [:]
|
||||
fileprivate var boolCache: [StoreKey: Bool] = [:]
|
||||
fileprivate var dateCache: [StoreKey: Date] = [:]
|
||||
fileprivate var stringCache: [StoreKey: String] = [:]
|
||||
fileprivate var urlCache: [StoreKey: URL] = [:]
|
||||
fileprivate var endpointArrayCache: [StoreKey: [Endpoint]] = [:]
|
||||
fileprivate var stringDictCache: [StoreKey: [String: String]] = [:]
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) -> T? {
|
||||
T.get(self, key: key.rawValue)
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T?) {
|
||||
T.set(self, key: key.rawValue, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.intCache
|
||||
static func fromValue(_ value: Int) -> Int { value }
|
||||
static func toValue(_ value: Int) -> Int { value }
|
||||
}
|
||||
|
||||
extension Bool: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.boolCache
|
||||
static func fromValue(_ value: Int) -> Bool { value == 1 }
|
||||
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
|
||||
}
|
||||
|
||||
extension Date: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.dateCache
|
||||
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
|
||||
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
|
||||
}
|
||||
|
||||
extension String: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.stringCache
|
||||
static func fromValue(_ value: String) -> String { value }
|
||||
static func toValue(_ value: String) -> String { value }
|
||||
}
|
||||
|
||||
extension URL: StoreConvertible {
|
||||
static let cacheKeyPath = \StoreCache.urlCache
|
||||
static func fromValue(_ value: String) throws(StoreError) -> URL {
|
||||
guard let url = URL(string: value) else {
|
||||
throw StoreError.invalidURL(value)
|
||||
@@ -69,78 +109,52 @@ extension StoreConvertible where Self: Codable, StorageType == String {
|
||||
}
|
||||
}
|
||||
|
||||
extension Array: StoreConvertible where Element: Codable {
|
||||
extension Array: StoreConvertible where Element == Endpoint {
|
||||
static let cacheKeyPath = \StoreCache.endpointArrayCache
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
|
||||
extension Dictionary: StoreConvertible where Key == String, Value == String {
|
||||
static let cacheKeyPath = \StoreCache.stringDictCache
|
||||
typealias StorageType = String
|
||||
}
|
||||
|
||||
class StoreRepository {
|
||||
private let db: DatabasePool
|
||||
|
||||
init(db: DatabasePool) {
|
||||
self.db = db
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
|
||||
extension Store {
|
||||
static let cache = StoreCache()
|
||||
|
||||
static func get<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>) throws -> T?
|
||||
where T.StorageType == Int {
|
||||
if let cached = cache.get(key) { return cached }
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
if let value = try query.fetchOne(conn) ?? nil {
|
||||
let converted = try T.fromValue(value)
|
||||
cache.set(key, value: converted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
|
||||
static func get<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>) throws -> T?
|
||||
where T.StorageType == String {
|
||||
if let cached = cache.get(key) { return cached }
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
if let value = try query.fetchOne(conn) ?? nil {
|
||||
let converted = try T.fromValue(value)
|
||||
cache.set(key, value: converted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
|
||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
static func set<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>, value: T) throws
|
||||
where T.StorageType == Int {
|
||||
let converted = try T.toValue(value)
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: converted) }.execute(conn)
|
||||
cache.set(key, value: value)
|
||||
}
|
||||
|
||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
|
||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||
return try T.fromValue(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
|
||||
let value = try T.toValue(value)
|
||||
try await db.write { conn in
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||
}
|
||||
static func set<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>, value: T) throws
|
||||
where T.StorageType == String {
|
||||
let converted = try T.toValue(value)
|
||||
try Store.upsert { Store(id: key.rawValue, stringValue: converted, intValue: nil) }.execute(conn)
|
||||
cache.set(key, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,160 @@
|
||||
import GRDB
|
||||
import SQLiteData
|
||||
|
||||
extension QueryExpression where QueryValue: _OptionalProtocol {
|
||||
// asserts column result cannot be nil
|
||||
var unwrapped: SQLQueryExpression<QueryValue.Wrapped> {
|
||||
SQLQueryExpression(self.queryFragment, as: QueryValue.Wrapped.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var unixTime: Date.UnixTimeRepresentation {
|
||||
return Date.UnixTimeRepresentation(queryOutput: self)
|
||||
}
|
||||
}
|
||||
|
||||
@Table("asset_face_entity")
|
||||
struct AssetFace {
|
||||
struct AssetFace: Identifiable {
|
||||
let id: String
|
||||
let assetId: String
|
||||
let personId: String?
|
||||
@Column("asset_id")
|
||||
let assetId: RemoteAsset.ID
|
||||
@Column("person_id")
|
||||
let personId: Person.ID?
|
||||
@Column("image_width")
|
||||
let imageWidth: Int
|
||||
@Column("image_height")
|
||||
let imageHeight: Int
|
||||
@Column("bounding_box_x1")
|
||||
let boundingBoxX1: Int
|
||||
@Column("bounding_box_y1")
|
||||
let boundingBoxY1: Int
|
||||
@Column("bounding_box_x2")
|
||||
let boundingBoxX2: Int
|
||||
@Column("bounding_box_y2")
|
||||
let boundingBoxY2: Int
|
||||
let sourceType: String
|
||||
@Column("source_type")
|
||||
let sourceType: SourceType
|
||||
}
|
||||
|
||||
@Table("auth_user_entity")
|
||||
struct AuthUser {
|
||||
struct AuthUser: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
@Column("is_admin")
|
||||
let isAdmin: Bool
|
||||
@Column("has_profile_image")
|
||||
let hasProfileImage: Bool
|
||||
@Column("profile_changed_at")
|
||||
let profileChangedAt: Date
|
||||
@Column("avatar_color")
|
||||
let avatarColor: AvatarColor
|
||||
@Column("quota_size_in_bytes")
|
||||
let quotaSizeInBytes: Int
|
||||
@Column("quota_usage_in_bytes")
|
||||
let quotaUsageInBytes: Int
|
||||
@Column("pin_code")
|
||||
let pinCode: String?
|
||||
}
|
||||
|
||||
@Table("local_album_entity")
|
||||
struct LocalAlbum {
|
||||
struct LocalAlbum: Identifiable {
|
||||
let id: String
|
||||
@Column("backup_selection")
|
||||
let backupSelection: BackupSelection
|
||||
let linkedRemoteAlbumId: String?
|
||||
@Column("linked_remote_album_id")
|
||||
let linkedRemoteAlbumId: RemoteAlbum.ID?
|
||||
@Column("marker")
|
||||
let marker_: Bool?
|
||||
let name: String
|
||||
@Column("is_ios_shared_album")
|
||||
let isIosSharedAlbum: Bool
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
extension LocalAlbum {
|
||||
static let selected = Self.where { $0.backupSelection.eq(BackupSelection.selected) }
|
||||
static let excluded = Self.where { $0.backupSelection.eq(BackupSelection.excluded) }
|
||||
}
|
||||
|
||||
@Table("local_album_asset_entity")
|
||||
struct LocalAlbumAsset {
|
||||
let id: ID
|
||||
@Column("marker")
|
||||
let marker_: String?
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalAlbumAsset {
|
||||
static let selected = Self.where {
|
||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.selected.select(\.id))
|
||||
}
|
||||
static let excluded = Self.where {
|
||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Table("local_asset_entity")
|
||||
struct LocalAsset {
|
||||
struct LocalAsset: Identifiable {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let createdAt: Date
|
||||
@Column("created_at")
|
||||
let createdAt: String
|
||||
@Column("duration_in_seconds")
|
||||
let durationInSeconds: Int?
|
||||
let height: Int?
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
let name: String
|
||||
let orientation: String
|
||||
let type: Int
|
||||
let updatedAt: Date
|
||||
let type: AssetType
|
||||
@Column("updated_at")
|
||||
let updatedAt: String
|
||||
let width: Int?
|
||||
|
||||
static func getCandidates() -> Where<LocalAsset> {
|
||||
return Self.where { local in
|
||||
LocalAlbumAsset.selected.exists()
|
||||
&& !LocalAlbumAsset.excluded.exists()
|
||||
&& !RemoteAsset.where {
|
||||
local.checksum.eq($0.checksum)
|
||||
&& $0.ownerId.eq(Store.select(\.stringValue).where { $0.id.eq(StoreKey.currentUser.rawValue) }.unwrapped)
|
||||
}.exists()
|
||||
&& !UploadTask.where { $0.localId.eq(local.id) }.exists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetCandidate {
|
||||
let id: LocalAsset.ID
|
||||
let type: AssetType
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetDownloadData {
|
||||
let checksum: String?
|
||||
let createdAt: String
|
||||
let livePhotoVideoId: RemoteAsset.ID?
|
||||
let localId: LocalAsset.ID
|
||||
let taskId: UploadTask.ID
|
||||
let updatedAt: String
|
||||
}
|
||||
|
||||
@Selection
|
||||
struct LocalAssetUploadData {
|
||||
let filePath: URL
|
||||
let priority: Float
|
||||
let taskId: UploadTask.ID
|
||||
let type: AssetType
|
||||
}
|
||||
|
||||
@Table("memory_asset_entity")
|
||||
@@ -73,63 +163,89 @@ struct MemoryAsset {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("memory_entity")
|
||||
struct Memory {
|
||||
struct Memory: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
@Column("deleted_at")
|
||||
let deletedAt: Date?
|
||||
let ownerId: String
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
let type: MemoryType
|
||||
let data: String
|
||||
@Column("is_saved")
|
||||
let isSaved: Bool
|
||||
@Column("memory_at")
|
||||
let memoryAt: Date
|
||||
@Column("seen_at")
|
||||
let seenAt: Date?
|
||||
@Column("show_at")
|
||||
let showAt: Date?
|
||||
@Column("hide_at")
|
||||
let hideAt: Date?
|
||||
}
|
||||
|
||||
@Table("partner_entity")
|
||||
struct Partner {
|
||||
let id: ID
|
||||
@Column("in_timeline")
|
||||
let inTimeline: Bool
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("shared_by_id")
|
||||
let sharedById: String
|
||||
@Column("shared_with_id")
|
||||
let sharedWithId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("person_entity")
|
||||
struct Person {
|
||||
struct Person: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
@Column("owner_id")
|
||||
let ownerId: String
|
||||
let name: String
|
||||
let faceAssetId: String?
|
||||
@Column("face_asset_id")
|
||||
let faceAssetId: AssetFace.ID?
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
@Column("is_hidden")
|
||||
let isHidden: Bool
|
||||
let color: String?
|
||||
@Column("birth_date")
|
||||
let birthDate: Date?
|
||||
}
|
||||
|
||||
@Table("remote_album_entity")
|
||||
struct RemoteAlbum {
|
||||
struct RemoteAlbum: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
let description: String?
|
||||
@Column("is_activity_enabled")
|
||||
let isActivityEnabled: Bool
|
||||
let name: String
|
||||
let order: Int
|
||||
@Column("owner_id")
|
||||
let ownerId: String
|
||||
let thumbnailAssetId: String?
|
||||
@Column("thumbnail_asset_id")
|
||||
let thumbnailAssetId: RemoteAsset.ID?
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
@@ -139,7 +255,9 @@ struct RemoteAlbumAsset {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("asset_id")
|
||||
let assetId: String
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
}
|
||||
}
|
||||
@@ -151,40 +269,55 @@ struct RemoteAlbumUser {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("album_id")
|
||||
let albumId: String
|
||||
@Column("user_id")
|
||||
let userId: String
|
||||
}
|
||||
}
|
||||
|
||||
@Table("remote_asset_entity")
|
||||
struct RemoteAsset {
|
||||
struct RemoteAsset: Identifiable {
|
||||
let id: String
|
||||
let checksum: String?
|
||||
let checksum: String
|
||||
@Column("is_favorite")
|
||||
let isFavorite: Bool
|
||||
@Column("deleted_at")
|
||||
let deletedAt: Date?
|
||||
let isFavorite: Int
|
||||
let libraryId: String?
|
||||
let livePhotoVideoId: String?
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
@Column("local_date_time")
|
||||
let localDateTime: Date?
|
||||
let orientation: String
|
||||
let ownerId: String
|
||||
let stackId: String?
|
||||
let visibility: Int
|
||||
@Column("thumb_hash")
|
||||
let thumbHash: String?
|
||||
@Column("library_id")
|
||||
let libraryId: String?
|
||||
@Column("live_photo_video_id")
|
||||
let livePhotoVideoId: String?
|
||||
@Column("stack_id")
|
||||
let stackId: Stack.ID?
|
||||
let visibility: AssetVisibility
|
||||
}
|
||||
|
||||
@Table("remote_exif_entity")
|
||||
struct RemoteExif {
|
||||
@Column(primaryKey: true)
|
||||
let assetId: String
|
||||
@Column("asset_id", primaryKey: true)
|
||||
let assetId: RemoteAsset.ID
|
||||
let city: String?
|
||||
let state: String?
|
||||
let country: String?
|
||||
@Column("date_time_original")
|
||||
let dateTimeOriginal: Date?
|
||||
let description: String?
|
||||
let height: Int?
|
||||
let width: Int?
|
||||
@Column("exposure_time")
|
||||
let exposureTime: String?
|
||||
@Column("f_number")
|
||||
let fNumber: Double?
|
||||
@Column("file_size")
|
||||
let fileSize: Int?
|
||||
@Column("focal_length")
|
||||
let focalLength: Double?
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
@@ -193,34 +326,110 @@ struct RemoteExif {
|
||||
let model: String?
|
||||
let lens: String?
|
||||
let orientation: String?
|
||||
@Column("time_zone")
|
||||
let timeZone: String?
|
||||
let rating: Int?
|
||||
@Column("projection_type")
|
||||
let projectionType: String?
|
||||
}
|
||||
|
||||
@Table("stack_entity")
|
||||
struct Stack {
|
||||
struct Stack: Identifiable {
|
||||
let id: String
|
||||
@Column("created_at")
|
||||
let createdAt: Date
|
||||
@Column("updated_at")
|
||||
let updatedAt: Date
|
||||
let ownerId: String
|
||||
@Column("owner_id")
|
||||
let ownerId: User.ID
|
||||
@Column("primary_asset_id")
|
||||
let primaryAssetId: String
|
||||
}
|
||||
|
||||
@Table("store_entity")
|
||||
struct Store {
|
||||
struct Store: Identifiable {
|
||||
let id: StoreKey
|
||||
@Column("string_value")
|
||||
let stringValue: String?
|
||||
@Column("int_value")
|
||||
let intValue: Int?
|
||||
}
|
||||
|
||||
@Table("upload_tasks")
|
||||
struct UploadTask: Identifiable {
|
||||
let id: Int64
|
||||
let attempts: Int
|
||||
@Column("created_at", as: Date.UnixTimeRepresentation.self)
|
||||
let createdAt: Date
|
||||
@Column("file_path")
|
||||
var filePath: URL?
|
||||
@Column("is_live_photo")
|
||||
let isLivePhoto: Bool?
|
||||
@Column("last_error")
|
||||
let lastError: UploadErrorCode?
|
||||
@Column("live_photo_video_id")
|
||||
let livePhotoVideoId: RemoteAsset.ID?
|
||||
@Column("local_id")
|
||||
var localId: LocalAsset.ID?
|
||||
let method: UploadMethod
|
||||
var priority: Float
|
||||
@Column("retry_after", as: Date?.UnixTimeRepresentation.self)
|
||||
let retryAfter: Date?
|
||||
let status: TaskStatus
|
||||
|
||||
static func retryOrFail(code: UploadErrorCode, status: TaskStatus) -> Update<UploadTask, ()> {
|
||||
return Self.update { row in
|
||||
row.status = Case().when(row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.downloadPending).else(status)
|
||||
row.attempts += 1
|
||||
row.lastError = code
|
||||
row.retryAfter = #sql("unixepoch('now') + (\(4 << row.attempts))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Table("upload_task_stats")
|
||||
struct UploadTaskStat {
|
||||
@Column("pending_downloads")
|
||||
let pendingDownloads: Int
|
||||
@Column("pending_uploads")
|
||||
let pendingUploads: Int
|
||||
@Column("queued_downloads")
|
||||
let queuedDownloads: Int
|
||||
@Column("queued_uploads")
|
||||
let queuedUploads: Int
|
||||
@Column("failed_downloads")
|
||||
let failedDownloads: Int
|
||||
@Column("failed_uploads")
|
||||
let failedUploads: Int
|
||||
@Column("completed_uploads")
|
||||
let completedUploads: Int
|
||||
@Column("skipped_uploads")
|
||||
let skippedUploads: Int
|
||||
|
||||
static let availableDownloadSlots = Self.select {
|
||||
TaskConfig.maxPendingDownloads - ($0.pendingDownloads + $0.queuedDownloads)
|
||||
}
|
||||
|
||||
static let availableUploadSlots = Self.select {
|
||||
TaskConfig.maxPendingUploads - ($0.pendingUploads + $0.queuedUploads)
|
||||
}
|
||||
|
||||
static let availableSlots = Self.select {
|
||||
TaskConfig.maxPendingUploads + TaskConfig.maxPendingDownloads
|
||||
- ($0.pendingDownloads + $0.queuedDownloads + $0.pendingUploads + $0.queuedUploads)
|
||||
}
|
||||
}
|
||||
|
||||
@Table("user_entity")
|
||||
struct User {
|
||||
struct User: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
@Column("has_profile_image")
|
||||
let hasProfileImage: Bool
|
||||
@Column("profile_changed_at")
|
||||
let profileChangedAt: Date
|
||||
@Column("avatar_color")
|
||||
let avatarColor: AvatarColor
|
||||
}
|
||||
|
||||
@@ -231,6 +440,7 @@ struct UserMetadata {
|
||||
|
||||
@Selection
|
||||
struct ID {
|
||||
@Column("user_id")
|
||||
let userId: String
|
||||
let key: Date
|
||||
}
|
||||
|
||||
@@ -52,6 +52,23 @@ extension PHAsset {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLivePhotoResource() -> PHAssetResource? {
|
||||
let resources = PHAssetResource.assetResources(for: self)
|
||||
|
||||
var livePhotoResource: PHAssetResource?
|
||||
for resource in resources {
|
||||
if resource.type == .fullSizePairedVideo {
|
||||
return resource
|
||||
}
|
||||
|
||||
if resource.type == .pairedVideo {
|
||||
livePhotoResource = resource
|
||||
}
|
||||
}
|
||||
|
||||
return livePhotoResource
|
||||
}
|
||||
|
||||
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||
switch mediaType {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import SQLiteData
|
||||
|
||||
class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||||
private static let stateLock = NSLock()
|
||||
private static var transferStates: [Int64: NetworkTransferState] = [:]
|
||||
private static var responseData: [Int64: Data] = [:]
|
||||
private static let jsonDecoder = JSONDecoder()
|
||||
|
||||
private let db: DatabasePool
|
||||
private let statusListener: StatusEventListener
|
||||
private let progressListener: ProgressEventListener
|
||||
weak var downloadQueue: DownloadQueue?
|
||||
weak var uploadQueue: UploadQueue?
|
||||
|
||||
init(db: DatabasePool, statusListener: StatusEventListener, progressListener: ProgressEventListener) {
|
||||
self.db = db
|
||||
self.statusListener = statusListener
|
||||
self.progressListener = progressListener
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
stateLock.withLock {
|
||||
transferStates.removeAll()
|
||||
responseData.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard let taskIdStr = dataTask.taskDescription,
|
||||
let taskId = Int64(taskIdStr)
|
||||
else { return }
|
||||
|
||||
Self.stateLock.withLock {
|
||||
if var response = Self.responseData[taskId] {
|
||||
response.append(data)
|
||||
} else {
|
||||
Self.responseData[taskId] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
Task {
|
||||
defer {
|
||||
downloadQueue?.startQueueProcessing()
|
||||
uploadQueue?.startQueueProcessing()
|
||||
}
|
||||
|
||||
guard let taskDescriptionId = task.taskDescription,
|
||||
let taskId = Int64(taskDescriptionId)
|
||||
else {
|
||||
return dPrint("Unexpected: task without session ID completed")
|
||||
}
|
||||
|
||||
defer {
|
||||
Self.stateLock.withLock { let _ = Self.transferStates.removeValue(forKey: taskId) }
|
||||
}
|
||||
|
||||
if let responseData = Self.stateLock.withLock({ Self.responseData.removeValue(forKey: taskId) }),
|
||||
let httpResponse = task.response as? HTTPURLResponse
|
||||
{
|
||||
switch httpResponse.statusCode {
|
||||
case 200, 201:
|
||||
do {
|
||||
let response = try Self.jsonDecoder.decode(UploadSuccessResponse.self, from: responseData)
|
||||
return await handleSuccess(taskId: taskId, response: response)
|
||||
} catch {
|
||||
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
||||
}
|
||||
case 400..<500:
|
||||
dPrint(
|
||||
"Response \(httpResponse.statusCode): \(String(data: responseData, encoding: .utf8) ?? "No response body")"
|
||||
)
|
||||
return await handleFailure(taskId: taskId, code: .badRequest)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let urlError = error as? URLError else {
|
||||
return await handleFailure(taskId: taskId)
|
||||
}
|
||||
|
||||
if #available(iOS 17, *), let resumeData = urlError.uploadTaskResumeData {
|
||||
return await handleFailure(taskDescriptionId: taskDescriptionId, session: session, resumeData: resumeData)
|
||||
}
|
||||
|
||||
let code: UploadErrorCode =
|
||||
switch urlError.backgroundTaskCancelledReason {
|
||||
case .backgroundUpdatesDisabled: .backgroundUpdatesDisabled
|
||||
case .insufficientSystemResources: .outOfResources
|
||||
case .userForceQuitApplication: .forceQuit
|
||||
default:
|
||||
switch urlError.code {
|
||||
case .networkConnectionLost, .notConnectedToInternet: .networkError
|
||||
case .timedOut: .uploadTimeout
|
||||
case .resourceUnavailable, .fileDoesNotExist: .fileNotFound
|
||||
default: .unknown
|
||||
}
|
||||
}
|
||||
await handleFailure(taskId: taskId, code: code)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didSendBodyData bytesSent: Int64,
|
||||
totalBytesSent: Int64,
|
||||
totalBytesExpectedToSend: Int64
|
||||
) {
|
||||
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
||||
let currentTime = Date()
|
||||
let state = Self.stateLock.withLock {
|
||||
if let existing = Self.transferStates[taskId] {
|
||||
return existing
|
||||
}
|
||||
let new = NetworkTransferState(
|
||||
lastUpdateTime: currentTime,
|
||||
totalBytesTransferred: totalBytesSent,
|
||||
currentSpeed: nil
|
||||
)
|
||||
Self.transferStates[taskId] = new
|
||||
return new
|
||||
}
|
||||
|
||||
let timeDelta = currentTime.timeIntervalSince(state.lastUpdateTime)
|
||||
guard timeDelta > 0 else { return }
|
||||
|
||||
let bytesDelta = totalBytesSent - state.totalBytesTransferred
|
||||
let instantSpeed = Double(bytesDelta) / timeDelta
|
||||
let currentSpeed =
|
||||
if let previousSpeed = state.currentSpeed {
|
||||
TaskConfig.transferSpeedAlpha * instantSpeed + (1 - TaskConfig.transferSpeedAlpha) * previousSpeed
|
||||
} else {
|
||||
instantSpeed
|
||||
}
|
||||
state.currentSpeed = currentSpeed
|
||||
state.lastUpdateTime = currentTime
|
||||
state.totalBytesTransferred = totalBytesSent
|
||||
self.progressListener.onTaskProgress(
|
||||
UploadApiTaskProgress(
|
||||
id: sessionTaskId,
|
||||
progress: Double(totalBytesSent) / Double(totalBytesExpectedToSend),
|
||||
speed: currentSpeed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
||||
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
||||
do {
|
||||
try await db.write { conn in
|
||||
let task = try UploadTask.update { $0.status = .uploadComplete }.where({ $0.id.eq(taskId) })
|
||||
.returning(\.self).fetchOne(conn)
|
||||
guard let task, let isLivePhoto = task.isLivePhoto, isLivePhoto, task.livePhotoVideoId == nil else { return }
|
||||
try UploadTask.insert {
|
||||
UploadTask.Draft(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: true,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: response.id,
|
||||
localId: task.localId,
|
||||
method: .multipart,
|
||||
priority: 0.7,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
}.execute(conn)
|
||||
}
|
||||
dPrint("Updated upload success status for session task \(taskId)")
|
||||
} catch {
|
||||
dPrint(
|
||||
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
||||
dPrint("Upload failed for task \(taskId) with code \(code)")
|
||||
try? await db.write { conn in
|
||||
try UploadTask.retryOrFail(code: code, status: .uploadFailed).where { $0.id.eq(taskId) }
|
||||
.execute(conn)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
private func handleFailure(taskDescriptionId: String, session: URLSession, resumeData: Data) async {
|
||||
dPrint("Resuming upload for task \(taskDescriptionId)")
|
||||
let resumeTask = session.uploadTask(withResumeData: resumeData)
|
||||
resumeTask.taskDescription = taskDescriptionId
|
||||
resumeTask.resume()
|
||||
}
|
||||
|
||||
private class NetworkTransferState {
|
||||
var lastUpdateTime: Date
|
||||
var totalBytesTransferred: Int64
|
||||
var currentSpeed: Double?
|
||||
|
||||
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
||||
self.lastUpdateTime = lastUpdateTime
|
||||
self.totalBytesTransferred = totalBytesTransferred
|
||||
self.currentSpeed = currentSpeed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import CryptoKit
|
||||
import Photos
|
||||
import SQLiteData
|
||||
|
||||
class DownloadQueue {
|
||||
private static let resourceManager = PHAssetResourceManager.default()
|
||||
private static var queueProcessingTask: Task<Void, Never>?
|
||||
private static var queueProcessingLock = NSLock()
|
||||
|
||||
private let db: DatabasePool
|
||||
private let uploadQueue: UploadQueue
|
||||
private let statusListener: StatusEventListener
|
||||
private let progressListener: ProgressEventListener
|
||||
|
||||
init(
|
||||
db: DatabasePool,
|
||||
uploadQueue: UploadQueue,
|
||||
statusListener: StatusEventListener,
|
||||
progressListener: ProgressEventListener
|
||||
) {
|
||||
self.db = db
|
||||
self.uploadQueue = uploadQueue
|
||||
self.statusListener = statusListener
|
||||
self.progressListener = progressListener
|
||||
NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) { [weak self] _ in
|
||||
dPrint("Network connected")
|
||||
self?.startQueueProcessing()
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueAssets(localIds: [String]) async throws {
|
||||
guard !localIds.isEmpty else { return dPrint("No assets to enqueue") }
|
||||
|
||||
defer { startQueueProcessing() }
|
||||
let candidates = try await db.read { conn in
|
||||
return try LocalAsset.all
|
||||
.where { asset in asset.id.in(localIds) }
|
||||
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
||||
.limit { _ in UploadTaskStat.availableSlots }
|
||||
.fetchAll(conn)
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else { return dPrint("No candidates to enqueue") }
|
||||
|
||||
try await db.write { conn in
|
||||
var draft = UploadTask.Draft(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: nil,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: nil,
|
||||
localId: "",
|
||||
method: .multipart,
|
||||
priority: 0.5,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
for candidate in candidates {
|
||||
draft.localId = candidate.id
|
||||
draft.priority = candidate.type == .image ? 0.9 : 0.8
|
||||
try UploadTask.insert {
|
||||
draft
|
||||
} onConflict: {
|
||||
($0.localId, $0.livePhotoVideoId)
|
||||
}.execute(conn)
|
||||
}
|
||||
}
|
||||
dPrint("Enqueued \(candidates.count) assets for upload")
|
||||
}
|
||||
|
||||
func startQueueProcessing() {
|
||||
dPrint("Starting download queue processing")
|
||||
Self.queueProcessingLock.withLock {
|
||||
guard Self.queueProcessingTask == nil else { return }
|
||||
Self.queueProcessingTask = Task {
|
||||
await startDownloads()
|
||||
Self.queueProcessingLock.withLock { Self.queueProcessingTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startDownloads() async {
|
||||
dPrint("Processing download queue")
|
||||
guard NetworkMonitor.shared.isConnected else {
|
||||
return dPrint("Download queue paused: network disconnected")
|
||||
}
|
||||
|
||||
do {
|
||||
let tasks: [LocalAssetDownloadData] = try await db.read({ conn in
|
||||
guard let backupEnabled = try Store.get(conn, StoreKey.enableBackup), backupEnabled else { return [] }
|
||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||
.where { task, asset in
|
||||
asset.checksum.isNot(nil) && task.status.eq(TaskStatus.downloadPending)
|
||||
&& task.attempts < TaskConfig.maxRetries
|
||||
&& (task.retryAfter.is(nil) || task.retryAfter.unwrapped <= Date().unixTime)
|
||||
&& (task.lastError.is(nil)
|
||||
|| !task.lastError.unwrapped.in([
|
||||
UploadErrorCode.assetNotFound, UploadErrorCode.resourceNotFound, UploadErrorCode.invalidResource,
|
||||
]))
|
||||
}
|
||||
.select { task, asset in
|
||||
LocalAssetDownloadData.Columns(
|
||||
checksum: asset.checksum,
|
||||
createdAt: asset.createdAt,
|
||||
livePhotoVideoId: task.livePhotoVideoId,
|
||||
localId: asset.id,
|
||||
taskId: task.id,
|
||||
updatedAt: asset.updatedAt
|
||||
)
|
||||
}
|
||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||
.limit { _, _ in UploadTaskStat.availableDownloadSlots }
|
||||
.fetchAll(conn)
|
||||
})
|
||||
if tasks.isEmpty { return dPrint("No download tasks to process") }
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var iterator = tasks.makeIterator()
|
||||
for _ in 0..<min(TaskConfig.maxActiveDownloads, tasks.count) {
|
||||
if let task = iterator.next() {
|
||||
group.addTask { await self.downloadAndQueue(task) }
|
||||
}
|
||||
}
|
||||
|
||||
while try await group.next() != nil {
|
||||
if let task = iterator.next() {
|
||||
group.addTask { await self.downloadAndQueue(task) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dPrint("Download queue error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAndQueue(_ task: LocalAssetDownloadData) async {
|
||||
defer { startQueueProcessing() }
|
||||
dPrint("Starting download for task \(task.taskId)")
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [task.localId], options: nil).firstObject
|
||||
else {
|
||||
dPrint("Asset not found")
|
||||
return handleFailure(task: task, code: .assetNotFound)
|
||||
}
|
||||
|
||||
let isLivePhoto = asset.mediaSubtypes.contains(.photoLive)
|
||||
let isMotion = isLivePhoto && task.livePhotoVideoId != nil
|
||||
guard let resource = isMotion ? asset.getLivePhotoResource() : asset.getResource() else {
|
||||
dPrint("Resource not found")
|
||||
return handleFailure(task: task, code: .resourceNotFound)
|
||||
}
|
||||
|
||||
guard let deviceId = (try? await db.read { conn in try Store.get(conn, StoreKey.deviceId) }) else {
|
||||
dPrint("Device ID not found")
|
||||
return handleFailure(task: task, code: .noDeviceId)
|
||||
}
|
||||
|
||||
let fileDir = TaskConfig.originalsDir
|
||||
let fileName = "\(resource.assetLocalIdentifier.replacingOccurrences(of: "/", with: "_"))_\(resource.type.rawValue)"
|
||||
let filePath = fileDir.appendingPathComponent(fileName)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileDir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
} catch {
|
||||
dPrint("Failed to create directory for download task \(task.taskId): \(error)")
|
||||
return handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
||||
}
|
||||
|
||||
do {
|
||||
try await db.write { conn in
|
||||
try UploadTask.update {
|
||||
$0.status = .downloadQueued
|
||||
$0.isLivePhoto = isLivePhoto
|
||||
$0.filePath = filePath
|
||||
}.where { $0.id.eq(task.taskId) }.execute(conn)
|
||||
}
|
||||
} catch {
|
||||
return dPrint("Failed to set file path for download task \(task.taskId): \(error)")
|
||||
}
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(id: String(task.taskId), filename: filePath.path, status: .downloadQueued)
|
||||
)
|
||||
|
||||
do {
|
||||
let hash = try await download(task: task, asset: asset, resource: resource, to: filePath, deviceId: deviceId)
|
||||
let status = try await db.write { conn in
|
||||
if let hash { try LocalAsset.update { $0.checksum = hash }.where { $0.id.eq(task.localId) }.execute(conn) }
|
||||
let status =
|
||||
if let hash, try RemoteAsset.select(\.rowid).where({ $0.checksum.eq(hash) }).fetchOne(conn) != nil {
|
||||
TaskStatus.uploadSkipped
|
||||
} else {
|
||||
TaskStatus.uploadPending
|
||||
}
|
||||
try UploadTask.update { $0.status = .uploadPending }.where { $0.id.eq(task.taskId) }.execute(conn)
|
||||
return status
|
||||
}
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: filePath.path,
|
||||
status: UploadApiStatus(rawValue: status.rawValue)!
|
||||
)
|
||||
)
|
||||
uploadQueue.startQueueProcessing()
|
||||
} catch {
|
||||
dPrint("Download failed for task \(task.taskId): \(error)")
|
||||
handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func download(
|
||||
task: LocalAssetDownloadData,
|
||||
asset: PHAsset,
|
||||
resource: PHAssetResource,
|
||||
to filePath: URL,
|
||||
deviceId: String
|
||||
) async throws
|
||||
-> String?
|
||||
{
|
||||
dPrint("Downloading asset resource \(resource.assetLocalIdentifier) of type \(resource.type.rawValue)")
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = true
|
||||
let (header, footer) = AssetData(
|
||||
deviceAssetId: task.localId,
|
||||
deviceId: deviceId,
|
||||
fileCreatedAt: task.createdAt,
|
||||
fileModifiedAt: task.updatedAt,
|
||||
fileName: resource.originalFilename,
|
||||
isFavorite: asset.isFavorite,
|
||||
livePhotoVideoId: nil
|
||||
).multipart()
|
||||
|
||||
guard let fileHandle = try? FileHandle.createOrOverwrite(atPath: filePath.path) else {
|
||||
dPrint("Failed to open file handle for download task \(task.taskId), path: \(filePath.path)")
|
||||
throw UploadError.fileCreationFailed
|
||||
}
|
||||
try fileHandle.write(contentsOf: header)
|
||||
|
||||
class RequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
var lastProgressTime = Date()
|
||||
var didStall = false
|
||||
}
|
||||
|
||||
var lastProgressTime = Date()
|
||||
nonisolated(unsafe) let progressListener = self.progressListener
|
||||
let taskIdStr = String(task.taskId)
|
||||
options.progressHandler = { progress in
|
||||
lastProgressTime = Date()
|
||||
progressListener.onTaskProgress(UploadApiTaskProgress(id: taskIdStr, progress: progress))
|
||||
}
|
||||
|
||||
let request = RequestRef()
|
||||
let timeoutTask = Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: TaskConfig.downloadCheckIntervalNs)
|
||||
request.didStall = Date().timeIntervalSince(lastProgressTime) > TaskConfig.downloadTimeoutS
|
||||
if request.didStall {
|
||||
if let requestId = request.id {
|
||||
Self.resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
var hasher = task.checksum == nil && task.livePhotoVideoId == nil ? Insecure.SHA1() : nil
|
||||
request.id = Self.resourceManager.requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
guard let requestId = request.id else { return }
|
||||
do {
|
||||
hasher?.update(data: data)
|
||||
try fileHandle.write(contentsOf: data)
|
||||
} catch {
|
||||
request.id = nil
|
||||
Self.resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
},
|
||||
completionHandler: { error in
|
||||
timeoutTask.cancel()
|
||||
switch error {
|
||||
case let e as NSError where e.domain == "CloudPhotoLibraryErrorDomain":
|
||||
dPrint("iCloud error during download: \(e)")
|
||||
let code: UploadErrorCode =
|
||||
switch e.code {
|
||||
case 1005: .iCloudRateLimit
|
||||
case 81: .iCloudThrottled
|
||||
default: .photosUnknownError
|
||||
}
|
||||
self.handleFailure(task: task, code: code, filePath: filePath)
|
||||
case let e as PHPhotosError:
|
||||
dPrint("Photos error during download: \(e)")
|
||||
let code: UploadErrorCode =
|
||||
switch e.code {
|
||||
case .notEnoughSpace: .notEnoughSpace
|
||||
case .missingResource: .resourceNotFound
|
||||
case .networkError: .networkError
|
||||
case .internalError: .photosInternalError
|
||||
case .invalidResource: .invalidResource
|
||||
case .operationInterrupted: .interrupted
|
||||
case .userCancelled where request.didStall: .downloadStalled
|
||||
case .userCancelled: .cancelled
|
||||
default: .photosUnknownError
|
||||
}
|
||||
self.handleFailure(task: task, code: code, filePath: filePath)
|
||||
case .some:
|
||||
dPrint("Unknown error during download: \(String(describing: error))")
|
||||
self.handleFailure(task: task, code: .unknown, filePath: filePath)
|
||||
case .none:
|
||||
dPrint("Download completed for task \(task.taskId)")
|
||||
do {
|
||||
try fileHandle.write(contentsOf: footer)
|
||||
continuation.resume(returning: hasher.map { hasher in Data(hasher.finalize()).base64EncodedString() })
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: filePath)
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} onCancel: {
|
||||
if let requestId = request.id {
|
||||
Self.resourceManager.cancelDataRequest(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(task: LocalAssetDownloadData, code: UploadErrorCode, filePath: URL? = nil) {
|
||||
dPrint("Handling failure for task \(task.taskId) with code \(code.rawValue)")
|
||||
do {
|
||||
if let filePath {
|
||||
try? FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
|
||||
try db.write { conn in
|
||||
try UploadTask.retryOrFail(code: code, status: .downloadFailed).where { $0.id.eq(task.taskId) }.execute(conn)
|
||||
}
|
||||
} catch {
|
||||
dPrint("Failed to update download failure status for task \(task.taskId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
class StatusEventListener: StreamStatusStreamHandler {
|
||||
var eventSink: PigeonEventSink<UploadApiTaskStatus>?
|
||||
|
||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskStatus>) {
|
||||
eventSink = sink
|
||||
}
|
||||
|
||||
func onTaskStatus(_ event: UploadApiTaskStatus) {
|
||||
if let eventSink = eventSink {
|
||||
eventSink.success(event)
|
||||
}
|
||||
}
|
||||
|
||||
func onEventsDone() {
|
||||
eventSink?.endOfStream()
|
||||
eventSink = nil
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressEventListener: StreamProgressStreamHandler {
|
||||
var eventSink: PigeonEventSink<UploadApiTaskProgress>?
|
||||
|
||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskProgress>) {
|
||||
eventSink = sink
|
||||
}
|
||||
|
||||
func onTaskProgress(_ event: UploadApiTaskProgress) {
|
||||
if let eventSink = eventSink {
|
||||
DispatchQueue.main.async { eventSink.success(event) }
|
||||
}
|
||||
}
|
||||
|
||||
func onEventsDone() {
|
||||
DispatchQueue.main.async {
|
||||
self.eventSink?.endOfStream()
|
||||
self.eventSink = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Network
|
||||
|
||||
class NetworkMonitor {
|
||||
static let shared = NetworkMonitor()
|
||||
private let monitor = NWPathMonitor()
|
||||
private(set) var isConnected = false
|
||||
private(set) var isExpensive = false
|
||||
|
||||
private init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
let wasConnected = self.isConnected
|
||||
self.isConnected = path.status == .satisfied
|
||||
self.isExpensive = path.isExpensive
|
||||
|
||||
if !wasConnected && self.isConnected {
|
||||
NotificationCenter.default.post(name: .networkDidConnect, object: nil)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import SQLiteData
|
||||
import StructuredFieldValues
|
||||
|
||||
class UploadQueue {
|
||||
private static let structuredEncoder = StructuredFieldValueEncoder()
|
||||
private static var queueProcessingTask: Task<Void, Never>?
|
||||
private static var queueProcessingLock = NSLock()
|
||||
|
||||
private let db: DatabasePool
|
||||
private let cellularSession: URLSession
|
||||
private let wifiOnlySession: URLSession
|
||||
private let statusListener: StatusEventListener
|
||||
|
||||
init(db: DatabasePool, cellularSession: URLSession, wifiOnlySession: URLSession, statusListener: StatusEventListener)
|
||||
{
|
||||
self.db = db
|
||||
self.cellularSession = cellularSession
|
||||
self.wifiOnlySession = wifiOnlySession
|
||||
self.statusListener = statusListener
|
||||
}
|
||||
|
||||
func enqueueFiles(paths: [String]) async throws {
|
||||
guard !paths.isEmpty else { return dPrint("No paths to enqueue") }
|
||||
|
||||
guard let deviceId = (try? await db.read { conn in try Store.get(conn, StoreKey.deviceId) }) else {
|
||||
throw StoreError.notFound
|
||||
}
|
||||
|
||||
defer { startQueueProcessing() }
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in
|
||||
let date = Date()
|
||||
try FileManager.default.createDirectory(
|
||||
at: TaskConfig.originalsDir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
|
||||
for path in paths {
|
||||
group.addTask {
|
||||
let inputURL = URL(fileURLWithPath: path, isDirectory: false)
|
||||
let outputURL = TaskConfig.originalsDir.appendingPathComponent(UUID().uuidString)
|
||||
let resources = try inputURL.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let (header, footer) = AssetData(
|
||||
deviceAssetId: "",
|
||||
deviceId: deviceId,
|
||||
fileCreatedAt: formatter.string(from: resources.creationDate ?? date),
|
||||
fileModifiedAt: formatter.string(from: resources.contentModificationDate ?? date),
|
||||
fileName: resources.name ?? inputURL.lastPathComponent,
|
||||
isFavorite: false,
|
||||
livePhotoVideoId: nil
|
||||
).multipart()
|
||||
|
||||
do {
|
||||
let writeHandle = try FileHandle.createOrOverwrite(atPath: outputURL.path)
|
||||
try writeHandle.write(contentsOf: header)
|
||||
let readHandle = try FileHandle(forReadingFrom: inputURL)
|
||||
|
||||
let bufferSize = 1024 * 1024
|
||||
while true {
|
||||
let data = try readHandle.read(upToCount: bufferSize)
|
||||
guard let data = data, !data.isEmpty else { break }
|
||||
try writeHandle.write(contentsOf: data)
|
||||
}
|
||||
try writeHandle.write(contentsOf: footer)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await group.waitForAll()
|
||||
}
|
||||
|
||||
try await db.write { conn in
|
||||
var draft = UploadTask.Draft(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: nil,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: nil,
|
||||
localId: "",
|
||||
method: .multipart,
|
||||
priority: 0.5,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
for path in paths {
|
||||
draft.filePath = URL(fileURLWithPath: path, isDirectory: false)
|
||||
try UploadTask.insert { draft }.execute(conn)
|
||||
}
|
||||
}
|
||||
dPrint("Enqueued \(paths.count) assets for upload")
|
||||
}
|
||||
|
||||
func startQueueProcessing() {
|
||||
dPrint("Starting upload queue processing")
|
||||
Self.queueProcessingLock.withLock {
|
||||
guard Self.queueProcessingTask == nil else { return }
|
||||
Self.queueProcessingTask = Task {
|
||||
await startUploads()
|
||||
Self.queueProcessingLock.withLock { Self.queueProcessingTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startUploads() async {
|
||||
dPrint("Processing download queue")
|
||||
guard NetworkMonitor.shared.isConnected,
|
||||
let backupEnabled = try? await db.read({ conn in try Store.get(conn, StoreKey.enableBackup) }),
|
||||
backupEnabled
|
||||
else { return dPrint("Download queue paused: network disconnected or backup disabled") }
|
||||
|
||||
do {
|
||||
let tasks: [LocalAssetUploadData] = try await db.read({ conn in
|
||||
guard let backupEnabled = try Store.get(conn, StoreKey.enableBackup), backupEnabled else { return [] }
|
||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||
.where { task, asset in
|
||||
asset.checksum.isNot(nil) && task.status.eq(TaskStatus.uploadPending)
|
||||
&& task.attempts < TaskConfig.maxRetries
|
||||
&& task.filePath.isNot(nil)
|
||||
}
|
||||
.select { task, asset in
|
||||
LocalAssetUploadData.Columns(
|
||||
filePath: task.filePath.unwrapped,
|
||||
priority: task.priority,
|
||||
taskId: task.id,
|
||||
type: asset.type
|
||||
)
|
||||
}
|
||||
.limit { task, _ in UploadTaskStat.availableUploadSlots }
|
||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||
.fetchAll(conn)
|
||||
})
|
||||
if tasks.isEmpty { return dPrint("No upload tasks to process") }
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for task in tasks {
|
||||
group.addTask { await self.startUpload(multipart: task) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dPrint("Upload queue error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func startUpload(multipart task: LocalAssetUploadData) async {
|
||||
dPrint("Uploading asset resource at \(task.filePath) of task \(task.taskId)")
|
||||
defer { startQueueProcessing() }
|
||||
|
||||
let (url, accessToken, session): (URL, String, URLSession)
|
||||
do {
|
||||
(url, accessToken, session) = try await db.read { conn in
|
||||
guard let url = try Store.get(conn, StoreKey.serverEndpoint),
|
||||
let accessToken = try Store.get(conn, StoreKey.accessToken)
|
||||
else {
|
||||
throw StoreError.notFound
|
||||
}
|
||||
|
||||
let session =
|
||||
switch task.type {
|
||||
case .image:
|
||||
(try? Store.get(conn, StoreKey.useWifiForUploadPhotos)) ?? false ? cellularSession : wifiOnlySession
|
||||
case .video:
|
||||
(try? Store.get(conn, StoreKey.useWifiForUploadVideos)) ?? false ? cellularSession : wifiOnlySession
|
||||
default: wifiOnlySession
|
||||
}
|
||||
return (url, accessToken, session)
|
||||
}
|
||||
} catch {
|
||||
dPrint("Upload failed for \(task.taskId), could not retrieve server URL or access token: \(error)")
|
||||
return handleFailure(task: task, code: .noServerUrl)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url.appendingPathComponent("/assets"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(accessToken, forHTTPHeaderField: UploadHeaders.userToken.rawValue)
|
||||
request.setValue(AssetData.contentType, forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let sessionTask = session.uploadTask(with: request, fromFile: task.filePath)
|
||||
sessionTask.taskDescription = String(task.taskId)
|
||||
sessionTask.priority = task.priority
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: task.filePath) // upload task already copied the file
|
||||
try await db.write { conn in
|
||||
try UploadTask.update { row in
|
||||
row.status = .uploadQueued
|
||||
row.filePath = nil
|
||||
}
|
||||
.where { $0.id.eq(task.taskId) }
|
||||
.execute(conn)
|
||||
}
|
||||
statusListener.onTaskStatus(
|
||||
UploadApiTaskStatus(
|
||||
id: String(task.taskId),
|
||||
filename: task.filePath.lastPathComponent,
|
||||
status: .uploadQueued,
|
||||
)
|
||||
)
|
||||
|
||||
sessionTask.resume()
|
||||
dPrint("Upload started for task \(task.taskId) using \(session == wifiOnlySession ? "WiFi" : "Cellular") session")
|
||||
} catch {
|
||||
dPrint("Upload failed for \(task.taskId), could not update queue status: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailure(task: LocalAssetUploadData, code: UploadErrorCode) {
|
||||
do {
|
||||
try db.write { conn in
|
||||
try UploadTask.retryOrFail(code: code, status: .uploadFailed).where { $0.id.eq(task.taskId) }.execute(conn)
|
||||
}
|
||||
} catch {
|
||||
dPrint("Failed to update upload failure status for task \(task.taskId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
func deepEqualsUploadTask(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||
return cleanLhsHashable == cleanRhsHashable
|
||||
|
||||
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||
for (index, element) in cleanLhsArray.enumerated() {
|
||||
if !deepEqualsUploadTask(element, cleanRhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||
if !deepEqualsUploadTask(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashUploadTask(value: Any?, hasher: inout Hasher) {
|
||||
if let valueList = value as? [AnyHashable] {
|
||||
for item in valueList { deepHashUploadTask(value: item, hasher: &hasher) }
|
||||
return
|
||||
}
|
||||
|
||||
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||
for key in valueDict.keys {
|
||||
hasher.combine(key)
|
||||
deepHashUploadTask(value: valueDict[key]!, hasher: &hasher)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let hashableValue = value as? AnyHashable {
|
||||
hasher.combine(hashableValue.hashValue)
|
||||
}
|
||||
|
||||
return hasher.combine(String(describing: value))
|
||||
}
|
||||
|
||||
|
||||
|
||||
enum UploadApiErrorCode: Int {
|
||||
case unknown = 0
|
||||
case assetNotFound = 1
|
||||
case fileNotFound = 2
|
||||
case resourceNotFound = 3
|
||||
case invalidResource = 4
|
||||
case encodingFailed = 5
|
||||
case writeFailed = 6
|
||||
case notEnoughSpace = 7
|
||||
case networkError = 8
|
||||
case photosInternalError = 9
|
||||
case photosUnknownError = 10
|
||||
case noServerUrl = 11
|
||||
case noDeviceId = 12
|
||||
case noAccessToken = 13
|
||||
case interrupted = 14
|
||||
case cancelled = 15
|
||||
case downloadStalled = 16
|
||||
case forceQuit = 17
|
||||
case outOfResources = 18
|
||||
case backgroundUpdatesDisabled = 19
|
||||
case uploadTimeout = 20
|
||||
case iCloudRateLimit = 21
|
||||
case iCloudThrottled = 22
|
||||
}
|
||||
|
||||
enum UploadApiStatus: Int {
|
||||
case downloadPending = 0
|
||||
case downloadQueued = 1
|
||||
case downloadFailed = 2
|
||||
case uploadPending = 3
|
||||
case uploadQueued = 4
|
||||
case uploadFailed = 5
|
||||
case uploadComplete = 6
|
||||
case uploadSkipped = 7
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct UploadApiTaskStatus: Hashable {
|
||||
var id: String
|
||||
var filename: String
|
||||
var status: UploadApiStatus
|
||||
var errorCode: UploadApiErrorCode? = nil
|
||||
var httpStatusCode: Int64? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskStatus? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let filename = pigeonVar_list[1] as! String
|
||||
let status = pigeonVar_list[2] as! UploadApiStatus
|
||||
let errorCode: UploadApiErrorCode? = nilOrValue(pigeonVar_list[3])
|
||||
let httpStatusCode: Int64? = nilOrValue(pigeonVar_list[4])
|
||||
|
||||
return UploadApiTaskStatus(
|
||||
id: id,
|
||||
filename: filename,
|
||||
status: status,
|
||||
errorCode: errorCode,
|
||||
httpStatusCode: httpStatusCode
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
filename,
|
||||
status,
|
||||
errorCode,
|
||||
httpStatusCode,
|
||||
]
|
||||
}
|
||||
static func == (lhs: UploadApiTaskStatus, rhs: UploadApiTaskStatus) -> Bool {
|
||||
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashUploadTask(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct UploadApiTaskProgress: Hashable {
|
||||
var id: String
|
||||
var progress: Double
|
||||
var speed: Double? = nil
|
||||
var totalBytes: Int64? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskProgress? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let progress = pigeonVar_list[1] as! Double
|
||||
let speed: Double? = nilOrValue(pigeonVar_list[2])
|
||||
let totalBytes: Int64? = nilOrValue(pigeonVar_list[3])
|
||||
|
||||
return UploadApiTaskProgress(
|
||||
id: id,
|
||||
progress: progress,
|
||||
speed: speed,
|
||||
totalBytes: totalBytes
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
progress,
|
||||
speed,
|
||||
totalBytes,
|
||||
]
|
||||
}
|
||||
static func == (lhs: UploadApiTaskProgress, rhs: UploadApiTaskProgress) -> Bool {
|
||||
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashUploadTask(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return UploadApiErrorCode(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return UploadApiStatus(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return UploadApiTaskStatus.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return UploadApiTaskProgress.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? UploadApiErrorCode {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? UploadApiStatus {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? UploadApiTaskStatus {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? UploadApiTaskProgress {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadTaskPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return UploadTaskPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return UploadTaskPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class UploadTaskPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = UploadTaskPigeonCodec(readerWriter: UploadTaskPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
var uploadTaskPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: UploadTaskPigeonCodecReaderWriter());
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol UploadApi {
|
||||
func initialize(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func refresh(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func cancelAll(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class UploadApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { UploadTaskPigeonCodec.shared }
|
||||
/// Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let initializeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.initialize\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
initializeChannel.setMessageHandler { _, reply in
|
||||
api.initialize { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
initializeChannel.setMessageHandler(nil)
|
||||
}
|
||||
let refreshChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.refresh\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
refreshChannel.setMessageHandler { _, reply in
|
||||
api.refresh { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
refreshChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelAllChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelAllChannel.setMessageHandler { _, reply in
|
||||
api.cancelAll { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cancelAllChannel.setMessageHandler(nil)
|
||||
}
|
||||
let enqueueAssetsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enqueueAssetsChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let localIdsArg = args[0] as! [String]
|
||||
api.enqueueAssets(localIds: localIdsArg) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let enqueueFilesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enqueueFilesChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let pathsArg = args[0] as! [String]
|
||||
api.enqueueFiles(paths: pathsArg) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
reply(wrapResult(nil))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueFilesChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
|
||||
private let wrapper: PigeonEventChannelWrapper<ReturnType>
|
||||
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
|
||||
|
||||
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
|
||||
self.wrapper = wrapper
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
pigeonSink = PigeonEventSink<ReturnType>(events)
|
||||
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
pigeonSink = nil
|
||||
wrapper.onCancel(withArguments: arguments)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
class PigeonEventChannelWrapper<ReturnType> {
|
||||
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
|
||||
func onCancel(withArguments arguments: Any?) {}
|
||||
}
|
||||
|
||||
class PigeonEventSink<ReturnType> {
|
||||
private let sink: FlutterEventSink
|
||||
|
||||
init(_ sink: @escaping FlutterEventSink) {
|
||||
self.sink = sink
|
||||
}
|
||||
|
||||
func success(_ value: ReturnType) {
|
||||
sink(value)
|
||||
}
|
||||
|
||||
func error(code: String, message: String?, details: Any?) {
|
||||
sink(FlutterError(code: code, message: message, details: details))
|
||||
}
|
||||
|
||||
func endOfStream() {
|
||||
sink(FlutterEndOfEventStream)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StreamStatusStreamHandler: PigeonEventChannelWrapper<UploadApiTaskStatus> {
|
||||
static func register(with messenger: FlutterBinaryMessenger,
|
||||
instanceName: String = "",
|
||||
streamHandler: StreamStatusStreamHandler) {
|
||||
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
|
||||
if !instanceName.isEmpty {
|
||||
channelName += ".\(instanceName)"
|
||||
}
|
||||
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskStatus>(wrapper: streamHandler)
|
||||
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
|
||||
channel.setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
|
||||
class StreamProgressStreamHandler: PigeonEventChannelWrapper<UploadApiTaskProgress> {
|
||||
static func register(with messenger: FlutterBinaryMessenger,
|
||||
instanceName: String = "",
|
||||
streamHandler: StreamProgressStreamHandler) {
|
||||
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
|
||||
if !instanceName.isEmpty {
|
||||
channelName += ".\(instanceName)"
|
||||
}
|
||||
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskProgress>(wrapper: streamHandler)
|
||||
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
|
||||
channel.setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import SQLiteData
|
||||
import StructuredFieldValues
|
||||
|
||||
extension FileHandle {
|
||||
static func createOrOverwrite(atPath path: String) throws -> FileHandle {
|
||||
let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
|
||||
guard fd >= 0 else {
|
||||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
||||
}
|
||||
return FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
}
|
||||
}
|
||||
|
||||
class UploadApiImpl: ImmichPlugin, UploadApi {
|
||||
private let db: DatabasePool
|
||||
private let downloadQueue: DownloadQueue
|
||||
private let uploadQueue: UploadQueue
|
||||
|
||||
private var isInitialized = false
|
||||
private let initLock = NSLock()
|
||||
|
||||
private var backupTask: Task<Void, Never>?
|
||||
private let backupLock = NSLock()
|
||||
|
||||
private let cellularSession: URLSession
|
||||
private let wifiOnlySession: URLSession
|
||||
|
||||
init(statusListener: StatusEventListener, progressListener: ProgressEventListener) {
|
||||
let dbUrl = try! FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
).appendingPathComponent("immich.sqlite")
|
||||
|
||||
self.db = try! DatabasePool(path: dbUrl.path)
|
||||
let cellularConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).cellular")
|
||||
cellularConfig.allowsCellularAccess = true
|
||||
cellularConfig.waitsForConnectivity = true
|
||||
let delegate = UploadApiDelegate(db: db, statusListener: statusListener, progressListener: progressListener)
|
||||
self.cellularSession = URLSession(configuration: cellularConfig, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
let wifiOnlyConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).wifi")
|
||||
wifiOnlyConfig.allowsCellularAccess = false
|
||||
wifiOnlyConfig.waitsForConnectivity = true
|
||||
self.wifiOnlySession = URLSession(configuration: wifiOnlyConfig, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
self.uploadQueue = UploadQueue(
|
||||
db: db,
|
||||
cellularSession: cellularSession,
|
||||
wifiOnlySession: wifiOnlySession,
|
||||
statusListener: statusListener
|
||||
)
|
||||
self.downloadQueue = DownloadQueue(
|
||||
db: db,
|
||||
uploadQueue: uploadQueue,
|
||||
statusListener: statusListener,
|
||||
progressListener: progressListener
|
||||
)
|
||||
delegate.downloadQueue = downloadQueue
|
||||
delegate.uploadQueue = uploadQueue
|
||||
}
|
||||
|
||||
func initialize(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task(priority: .high) {
|
||||
do {
|
||||
async let dbIds = db.read { conn in
|
||||
try UploadTask.select(\.id).where { $0.status.eq(TaskStatus.uploadQueued) }.fetchAll(conn)
|
||||
}
|
||||
async let cellularTasks = cellularSession.allTasks
|
||||
async let wifiTasks = wifiOnlySession.allTasks
|
||||
|
||||
var dbTaskIds = Set(try await dbIds)
|
||||
func validateTasks(_ tasks: [URLSessionTask]) {
|
||||
for task in tasks {
|
||||
if let taskIdStr = task.taskDescription, let taskId = Int64(taskIdStr), task.state != .canceling {
|
||||
dbTaskIds.remove(taskId)
|
||||
} else {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateTasks(await cellularTasks)
|
||||
validateTasks(await wifiTasks)
|
||||
|
||||
let orphanIds = Array(dbTaskIds)
|
||||
try await db.write { conn in
|
||||
try UploadTask.update {
|
||||
$0.filePath = nil
|
||||
$0.status = .downloadPending
|
||||
}
|
||||
.where { row in row.status.in([TaskStatus.downloadQueued, TaskStatus.uploadPending]) || row.id.in(orphanIds) }
|
||||
.execute(conn)
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: TaskConfig.originalsDir)
|
||||
initLock.withLock { isInitialized = true }
|
||||
startBackup()
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
startBackup()
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func startBackup() {
|
||||
dPrint("Starting backup task")
|
||||
guard (initLock.withLock { isInitialized }) else { return dPrint("Not initialized, skipping backup") }
|
||||
|
||||
backupLock.withLock {
|
||||
guard backupTask == nil else { return dPrint("Backup task already running") }
|
||||
backupTask = Task {
|
||||
await _startBackup()
|
||||
backupLock.withLock { backupTask = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAll(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
async let cellularTasks = cellularSession.allTasks
|
||||
async let wifiTasks = wifiOnlySession.allTasks
|
||||
|
||||
cancelSessionTasks(await cellularTasks)
|
||||
cancelSessionTasks(await wifiTasks)
|
||||
self.completeWhenActive(for: completion, with: .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await downloadQueue.enqueueAssets(localIds: localIds)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await uploadQueue.enqueueFiles(paths: paths)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSessionTasks(_ tasks: [URLSessionTask]) {
|
||||
dPrint("Canceling \(tasks.count) tasks")
|
||||
for task in tasks {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func _startBackup() async {
|
||||
defer { downloadQueue.startQueueProcessing() }
|
||||
do {
|
||||
let candidates = try await db.read { conn in
|
||||
return try LocalAsset.getCandidates()
|
||||
.where { asset in !UploadTask.where { task in task.localId.eq(asset.id) }.exists() }
|
||||
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
||||
.limit { _ in UploadTaskStat.availableSlots }
|
||||
.fetchAll(conn)
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else { return dPrint("No candidates for backup") }
|
||||
|
||||
try await db.write { conn in
|
||||
var draft = UploadTask.Draft(
|
||||
attempts: 0,
|
||||
createdAt: Date(),
|
||||
filePath: nil,
|
||||
isLivePhoto: nil,
|
||||
lastError: nil,
|
||||
livePhotoVideoId: nil,
|
||||
localId: "",
|
||||
method: .multipart,
|
||||
priority: 0.5,
|
||||
retryAfter: nil,
|
||||
status: .downloadPending,
|
||||
)
|
||||
for candidate in candidates {
|
||||
draft.localId = candidate.id
|
||||
draft.priority = candidate.type == .image ? 0.5 : 0.3
|
||||
try UploadTask.insert {
|
||||
draft
|
||||
} onConflict: {
|
||||
($0.localId, $0.livePhotoVideoId)
|
||||
}
|
||||
.execute(conn)
|
||||
}
|
||||
}
|
||||
dPrint("Backup enqueued \(candidates.count) assets for upload")
|
||||
} catch {
|
||||
print("Backup queue error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AssetData: StructuredFieldValue {
|
||||
static let structuredFieldType: StructuredFieldType = .dictionary
|
||||
|
||||
let deviceAssetId: String
|
||||
let deviceId: String
|
||||
let fileCreatedAt: String
|
||||
let fileModifiedAt: String
|
||||
let fileName: String
|
||||
let isFavorite: Bool
|
||||
let livePhotoVideoId: String?
|
||||
|
||||
static let boundary = "Boundary-\(UUID().uuidString)"
|
||||
static let deviceAssetIdField = "--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let deviceIdField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let fileCreatedAtField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let fileModifiedAtField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let isFavoriteField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let livePhotoVideoIdField =
|
||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"livePhotoVideoId\"\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
static let trueData = "true".data(using: .utf8)!
|
||||
static let falseData = "false".data(using: .utf8)!
|
||||
static let footer = "\r\n--\(boundary)--\r\n".data(using: .utf8)!
|
||||
static let contentType = "multipart/form-data; boundary=\(boundary)"
|
||||
|
||||
func multipart() -> (Data, Data) {
|
||||
var header = Data()
|
||||
header.append(Self.deviceAssetIdField)
|
||||
header.append(deviceAssetId.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.deviceIdField)
|
||||
header.append(deviceId.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.fileCreatedAtField)
|
||||
header.append(fileCreatedAt.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.fileModifiedAtField)
|
||||
header.append(fileModifiedAt.data(using: .utf8)!)
|
||||
|
||||
header.append(Self.isFavoriteField)
|
||||
header.append(isFavorite ? Self.trueData : Self.falseData)
|
||||
|
||||
if let livePhotoVideoId {
|
||||
header.append(Self.livePhotoVideoIdField)
|
||||
header.append(livePhotoVideoId.data(using: .utf8)!)
|
||||
}
|
||||
header.append(
|
||||
"\r\n--\(Self.boundary)\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"\(fileName)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||
.data(using: .utf8)!
|
||||
)
|
||||
return (header, Self.footer)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0
|
||||
).@count > 0 </string>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.immich.share</string>
|
||||
</array>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user