Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

37
mobile/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
fastlane/report.xml
Gemfile.lock
certs/

View file

@ -0,0 +1,26 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View file

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View file

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

5
mobile/ios/Gemfile Normal file
View file

@ -0,0 +1,5 @@
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods"
gem "abbrev" # Required for Ruby 3.4+

124
mobile/ios/Podfile Normal file
View file

@ -0,0 +1,124 @@
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
# share_handler addition start
target 'ShareExtension' do
inherit! :search_paths
pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models"
end
# share_handler addition end
end
post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
end
end
end
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
# Permission macros for the permission_handler (https://pub.dev/packages/permission_handler)
# Start of the permission_handler configuration
# Remove the # character in front of the permission you do want to use.
target.build_configurations.each do |config|
# You can enable the permissions needed here. For example to enable camera
# permission, just remove the `#` character in front so it looks like this:
#
# ## dart: PermissionGroup.camera
# 'PERMISSION_CAMERA=1'
#
# Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: PermissionGroup.calendar
# 'PERMISSION_EVENTS=1',
## dart: PermissionGroup.reminders
# 'PERMISSION_REMINDERS=1',
## dart: PermissionGroup.contacts
# 'PERMISSION_CONTACTS=1',
## dart: PermissionGroup.camera
# 'PERMISSION_CAMERA=1',
## dart: PermissionGroup.microphone
# 'PERMISSION_MICROPHONE=1',
## dart: PermissionGroup.speech
# 'PERMISSION_SPEECH_RECOGNIZER=1',
## dart: PermissionGroup.photos
'PERMISSION_PHOTOS=1',
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
# 'PERMISSION_LOCATION=1',
## dart: PermissionGroup.notification
'PERMISSION_NOTIFICATIONS=1',
## dart: PermissionGroup.mediaLibrary
# 'PERMISSION_MEDIA_LIBRARY=1',
## dart: PermissionGroup.sensors
# 'PERMISSION_SENSORS=1',
## dart: PermissionGroup.bluetooth
# 'PERMISSION_BLUETOOTH=1',
## dart: PermissionGroup.appTrackingTransparency
# 'PERMISSION_APP_TRACKING_TRANSPARENCY=1',
## dart: PermissionGroup.criticalAlerts
# 'PERMISSION_CRITICAL_ALERTS=1'
## The 'PERMISSION_LOCATION' macro enables the `locationWhenInUse` and `locationAlways` permission. If
## the application only requires `locationWhenInUse`, only specify the `PERMISSION_LOCATION_WHENINUSE`
## macro.
##
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
'PERMISSION_LOCATION=1',
]
end
# End of the permission_handler configuration
end
end

305
mobile/ios/Podfile.lock Normal file
View file

@ -0,0 +1,305 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_web_auth_2 (3.0.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- isar_community_flutter_libs (1.0.0):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MapLibre (6.14.0)
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 6.14.0)
- native_video_player (1.0.0):
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (3.7.1):
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
- share_handler_ios_models
- share_handler_ios/share_handler_ios_models (0.0.14):
- Flutter
- share_handler_ios_models
- share_handler_ios_models (0.0.9)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- 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/common
- sqlite3/fts5 (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- MapLibre
- SAMKeychain
- SDWebImage
- sqlite3
- SwiftyGif
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth_2:
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
isar_community_flutter_libs:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?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>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,177 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
"version" : "7.8.0"
}
},
{
"identity" : "opencombine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/OpenCombine/OpenCombine.git",
"state" : {
"revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2",
"version" : "0.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
"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",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
"version" : "1.5.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-structured-queries",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
"version" : "0.25.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"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",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
"version" : "1.7.0"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?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>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,168 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
"version" : "1.0.3"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
"version" : "7.8.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
"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",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
"version" : "1.5.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-structured-queries",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
"version" : "0.25.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"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",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
"version" : "1.7.0"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,65 @@
import BackgroundTasks
import Flutter
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 {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundWorkerApiImpl.registerBackgroundWorkers()
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-foundation")!)
}
if !registry.hasPlugin("org.cocoapods.photo-manager") {
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
}
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
}
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
}
if !registry.hasPlugin("org.cocoapods.network-info-plus") {
FPPNetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.network-info-plus")!)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
}
public static func cancelPlugins(with engine: FlutterEngine) {
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,354 @@
{
"images" : [
{
"filename" : "40.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "29.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "57.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "57x57"
},
{
"filename" : "114.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "57x57"
},
{
"filename" : "120.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "40.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "58.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "80.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "50.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "50x50"
},
{
"filename" : "100.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "50x50"
},
{
"filename" : "72.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "72x72"
},
{
"filename" : "144.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "72x72"
},
{
"filename" : "76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "152.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "167.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
},
{
"filename" : "48.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "24x24",
"subtype" : "38mm"
},
{
"filename" : "55.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "27.5x27.5",
"subtype" : "42mm"
},
{
"filename" : "58.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "87.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "66.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "33x33",
"subtype" : "45mm"
},
{
"filename" : "80.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "40x40",
"subtype" : "38mm"
},
{
"filename" : "88.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "44x44",
"subtype" : "40mm"
},
{
"filename" : "92.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "46x46",
"subtype" : "41mm"
},
{
"filename" : "100.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "50x50",
"subtype" : "44mm"
},
{
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "51x51",
"subtype" : "45mm"
},
{
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "54x54",
"subtype" : "49mm"
},
{
"filename" : "172.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "86x86",
"subtype" : "38mm"
},
{
"filename" : "196.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "98x98",
"subtype" : "42mm"
},
{
"filename" : "216.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "108x108",
"subtype" : "44mm"
},
{
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "117x117",
"subtype" : "45mm"
},
{
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "129x129",
"subtype" : "49mm"
},
{
"filename" : "1024.png",
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "102.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "45x45",
"subtype" : "41mm"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View file

@ -0,0 +1,365 @@
// 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 createConnectionError(withChannelName channelName: String) -> PigeonError {
return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
}
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 deepEqualsBackgroundWorker(_ 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 !deepEqualsBackgroundWorker(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 !deepEqualsBackgroundWorker(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 deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
/// Generated class from Pigeon that represents data sent in messages.
struct BackgroundWorkerSettings: Hashable {
var requiresCharging: Bool
var minimumDelaySeconds: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
let requiresCharging = pigeonVar_list[0] as! Bool
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
return BackgroundWorkerSettings(
requiresCharging: requiresCharging,
minimumDelaySeconds: minimumDelaySeconds
)
}
func toList() -> [Any?] {
return [
requiresCharging,
minimumDelaySeconds,
]
}
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
}
}
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? BackgroundWorkerSettings {
super.writeByte(129)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return BackgroundWorkerPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return BackgroundWorkerPigeonCodecWriter(data: data)
}
}
class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func saveNotificationMessage(title: String, body: String) throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class BackgroundWorkerFgHostApiSetup {
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableChannel.setMessageHandler { _, reply in
do {
try api.enable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
enableChannel.setMessageHandler(nil)
}
let saveNotificationMessageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.saveNotificationMessage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
saveNotificationMessageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let titleArg = args[0] as! String
let bodyArg = args[1] as! String
do {
try api.saveNotificationMessage(title: titleArg, body: bodyArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
saveNotificationMessageChannel.setMessageHandler(nil)
}
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
configureChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let settingsArg = args[0] as! BackgroundWorkerSettings
do {
try api.configure(settings: settingsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
configureChannel.setMessageHandler(nil)
}
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in
do {
try api.disable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
disableChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerBgHostApi {
func onInitialized() throws
func close() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class BackgroundWorkerBgHostApiSetup {
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
/// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
onInitializedChannel.setMessageHandler { _, reply in
do {
try api.onInitialized()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
onInitializedChannel.setMessageHandler(nil)
}
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
closeChannel.setMessageHandler { _, reply in
do {
try api.close()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
closeChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol BackgroundWorkerFlutterApiProtocol {
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
}
class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
private let binaryMessenger: FlutterBinaryMessenger
private let messageChannelSuffix: String
init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
self.binaryMessenger = binaryMessenger
self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
}
var codec: BackgroundWorkerPigeonCodec {
return BackgroundWorkerPigeonCodec.shared
}
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage([isRefreshArg, maxSecondsArg] as [Any?]) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
}
if listResponse.count > 1 {
let code: String = listResponse[0] as! String
let message: String? = nilOrValue(listResponse[1])
let details: String? = nilOrValue(listResponse[2])
completion(.failure(PigeonError(code: code, message: message, details: details)))
} else {
completion(.success(()))
}
}
}
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage(nil) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
}
if listResponse.count > 1 {
let code: String = listResponse[0] as! String
let message: String? = nilOrValue(listResponse[1])
let details: String? = nilOrValue(listResponse[2])
completion(.failure(PigeonError(code: code, message: message, details: details)))
} else {
completion(.success(()))
}
}
}
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage(nil) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
}
if listResponse.count > 1 {
let code: String = listResponse[0] as! String
let message: String? = nilOrValue(listResponse[1])
let details: String? = nilOrValue(listResponse[2])
completion(.failure(PigeonError(code: code, message: message, details: details)))
} else {
completion(.success(()))
}
}
}
}

View file

@ -0,0 +1,176 @@
import BackgroundTasks
import Flutter
enum BackgroundTaskType { case refresh, processing }
/*
* DEBUG: Testing Background Tasks in Xcode
*
* To test background task functionality during development:
* 1. Pause the application in Xcode debugger
* 2. In the debugger console, enter one of the following commands:
## For background refresh (short-running sync):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
## For background processing (long-running upload):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
* To simulate task expiration (useful for testing expiration handlers):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
* 3. Resume the application to see the background code execute
*
* NOTE: This must be tested on a physical device, not in the simulator.
* In testing, only the background processing task can be reliably simulated.
* These commands submit the respective task to BGTaskScheduler for immediate processing.
* Use the expiration commands to test how the app handles iOS terminating background tasks.
*/
/// The background worker which creates a new Flutter VM, communicates with it
/// to run the backup job, and then finishes execution and calls back to its callback handler.
/// This class manages a separate Flutter engine instance for background execution,
/// independent of the main UI Flutter engine.
class BackgroundWorker: BackgroundWorkerBgHostApi {
private let taskType: BackgroundTaskType
/// The maximum number of seconds to run the task before timing out
private let maxSeconds: Int?
/// Callback function to invoke when the background task completes
private let completionHandler: (_ success: Bool) -> Void
/// The Flutter engine created specifically for background execution.
/// This is a separate instance from the main Flutter engine that handles the UI.
/// It operates in its own isolate and doesn't share memory with the main engine.
/// Must be properly started, registered, and torn down during background execution.
private let engine = FlutterEngine(name: "BackgroundImmich")
/// Used to call methods on the flutter side
private var flutterApi: BackgroundWorkerFlutterApi?
/// Flag to track whether the background task has completed to prevent duplicate completions
private var isComplete = false
/**
* Initializes a new background worker with the specified task type and execution constraints.
* Creates a new Flutter engine instance for background execution and sets up the necessary
* communication channels between native iOS and Flutter code.
*
* - Parameters:
* - taskType: The type of background task to execute (upload or sync task)
* - maxSeconds: Optional maximum execution time in seconds before the task is cancelled
* - completionHandler: Callback function invoked when the task completes, with success status
*/
init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) {
self.taskType = taskType
self.maxSeconds = maxSeconds
self.completionHandler = completionHandler
// Should be initialized only after the engine starts running
self.flutterApi = nil
}
/**
* Starts the background Flutter engine and begins execution of the background task.
* Retrieves the callback handle from UserDefaults, looks up the Flutter callback,
* starts the engine, and sets up a timeout timer if specified.
*/
func run() {
// Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run(
withEntrypoint: "backgroundSyncNativeEntrypoint",
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
)
// Verify that the Flutter engine started successfully
if !isRunning {
complete(success: false)
return
}
// Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins
AppDelegate.registerPlugins(with: engine)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
self.close()
}
}
}
/**
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
* This method acts as a bridge between the native iOS background task system and Flutter.
*/
func onInitialized() throws {
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
}
/**
* Cancels the currently running background task, either due to timeout or external request.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond.
*/
func close() {
if isComplete {
return
}
flutterApi?.cancel { result in
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
}
/**
* Handles the result from Flutter API calls and determines the success/failure status.
* Converts Flutter's Result type to a simple boolean success indicator for task completion.
*
* - Parameter result: The result returned from a Flutter API call
*/
private func handleHostResult(result: Result<Void, PigeonError>) {
switch result {
case .success(): self.complete(success: true)
case .failure(_): self.close()
}
}
/**
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
* This method ensures that the background task is marked as complete, releases the Flutter engine,
* and notifies the caller of the task's success or failure. This is the final step in the
* background task lifecycle and should only be called once per task instance.
*
* - Parameter success: Indicates whether the background task completed successfully
*/
private func complete(success: Bool) {
if(isComplete) {
return
}
isComplete = true
AppDelegate.cancelPlugins(with: engine)
engine.destroyContext()
flutterApi = nil
completionHandler(success)
}
}

View file

@ -0,0 +1,127 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
}
func configure(settings: BackgroundWorkerSettings) throws {
// Android only
}
func saveNotificationMessage(title: String, body: String) throws {
// Android only
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
private static let taskSemaphore = DispatchSemaphore(value: 1)
public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
}
}
private static func scheduleRefreshWorker() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
} catch {
print("Could not schedule the refresh upload task \(error.localizedDescription)")
}
}
private static func scheduleProcessingWorker() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
do {
try BGTaskScheduler.shared.submit(backgroundProcessing)
} catch {
print("Could not schedule the processing upload task \(error.localizedDescription)")
}
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshWorker()
// If another task is running, cede the background time back to the OS
if taskSemaphore.wait(timeout: .now()) == .success {
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
} else {
task.setTaskCompleted(success: false)
}
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingWorker()
taskSemaphore.wait()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
}
/**
* Executes the background worker within the context of a background task.
* This method creates a BackgroundWorker, sets up task expiration handling,
* and manages the synchronization between the background task and the Flutter engine.
*
* - Parameters:
* - task: The iOS background task that provides the execution context
* - taskType: The type of background operation to perform (refresh or processing)
* - maxSeconds: Optional timeout for the operation in seconds
*/
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
defer { taskSemaphore.signal() }
let semaphore = DispatchSemaphore(value: 0)
var isSuccess = true
let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in
isSuccess = success
semaphore.signal()
}
task.expirationHandler = {
DispatchQueue.main.async {
backgroundWorker.close()
}
isSuccess = false
// Schedule a timer to signal the semaphore after 2 seconds
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
semaphore.signal()
}
}
DispatchQueue.main.async {
backgroundWorker.run()
}
semaphore.wait()
task.setTaskCompleted(success: isSuccess)
print("Background task completed with success: \(isSuccess)")
}
}

View file

@ -0,0 +1,408 @@
//
// BackgroundServicePlugin.swift
// Runner
//
// Created by Marty Fuhry on 2/14/23.
//
import Flutter
import BackgroundTasks
import path_provider_foundation
import CryptoKit
import Network
class BackgroundServicePlugin: NSObject, FlutterPlugin {
public static var flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback?
public static func setPluginRegistrantCallback(_ callback: FlutterPluginRegistrantCallback) {
flutterPluginRegistrantCallback = callback
}
// Pause the application in XCode, then enter
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.backgroundFetch"]
// or
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.backgroundProcessing"]
// Then resume the application see the background code run
// Tested on a physical device, not a simulator
// This will submit either the Fetch or Processing command to the BGTaskScheduler for immediate processing.
// In my tests, I can only get app.alextran.immich.backgroundProcessing simulated by running the above command
// This is the task ID in Info.plist to register as our background task ID
public static let backgroundFetchTaskID = "app.alextran.immich.backgroundFetch"
public static let backgroundProcessingTaskID = "app.alextran.immich.backgroundProcessing"
// Establish communication with the main isolate and set up the channel call
// to this BackgroundServicePlugion()
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "immich/foregroundChannel",
binaryMessenger: registrar.messenger()
)
let instance = BackgroundServicePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
registrar.addApplicationDelegate(instance)
}
// Registers the Flutter engine with the plugins, used by the other Background Flutter engine
public static func register(engine: FlutterEngine) {
GeneratedPluginRegistrant.register(with: engine)
}
// Registers the task IDs from the system so that we can process them here in this class
public static func registerBackgroundProcessing() {
let processingRegisterd = BGTaskScheduler.shared.register(
forTaskWithIdentifier: backgroundProcessingTaskID,
using: nil) { task in
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
let fetchRegisterd = BGTaskScheduler.shared.register(
forTaskWithIdentifier: backgroundFetchTaskID,
using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundFetch(task: task as! BGAppRefreshTask)
}
}
}
// Handles the channel methods from Flutter
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "enable":
handleBackgroundEnable(call: call, result: result)
break
case "configure":
handleConfigure(call: call, result: result)
break
case "disable":
handleDisable(call: call, result: result)
break
case "isEnabled":
handleIsEnabled(call: call, result: result)
break
case "isIgnoringBatteryOptimizations":
result(FlutterMethodNotImplemented)
break
case "lastBackgroundFetchTime":
let defaults = UserDefaults.standard
let lastRunTime = defaults.value(forKey: "last_background_fetch_run_time")
result(lastRunTime)
break
case "lastBackgroundProcessingTime":
let defaults = UserDefaults.standard
let lastRunTime = defaults.value(forKey: "last_background_processing_run_time")
result(lastRunTime)
break
case "numberOfBackgroundProcesses":
handleNumberOfProcesses(call: call, result: result)
break
case "backgroundAppRefreshEnabled":
handleBackgroundRefreshStatus(call: call, result: result)
break
case "digestFiles":
handleDigestFiles(call: call, result: result)
break
default:
result(FlutterMethodNotImplemented)
break
}
}
// Calculates the SHA-1 hash of each file from the list of paths provided
func handleDigestFiles(call: FlutterMethodCall, result: @escaping FlutterResult) {
let bufsize = 2 * 1024 * 1024
// Private error to throw if file cannot be read
enum DigestError: String, LocalizedError {
case NoFileHandle = "Cannot Open File Handle"
public var errorDescription: String? { self.rawValue }
}
// Parse the arguments or else fail
guard let args = call.arguments as? Array<String> else {
print("Cannot parse args as array: \(String(describing: call.arguments))")
result(FlutterError(code: "Malformed",
message: "Received args is not an Array<String>",
details: nil))
return
}
// Compute hash in background thread
DispatchQueue.global(qos: .background).async {
var hashes: [FlutterStandardTypedData?] = Array(repeating: nil, count: args.count)
for i in (0 ..< args.count) {
do {
guard let file = FileHandle(forReadingAtPath: args[i]) else { throw DigestError.NoFileHandle }
var hasher = Insecure.SHA1.init();
while autoreleasepool(invoking: {
let chunk = file.readData(ofLength: bufsize)
guard !chunk.isEmpty else { return false } // EOF
hasher.update(data: chunk)
return true // continue
}) { }
let digest = hasher.finalize()
hashes[i] = FlutterStandardTypedData(bytes: Data(Array(digest.makeIterator())))
} catch {
print("Cannot calculate the digest of the file \(args[i]) due to \(error.localizedDescription)")
}
}
// Return result in main thread
DispatchQueue.main.async {
result(Array(hashes))
}
}
}
// Called by the flutter code when enabled so that we can turn on the background services
// and save the callback information to communicate on this method channel
public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) {
// Needs to parse the arguments from the method call
guard let args = call.arguments as? Array<Any> else {
print("Cannot parse args as array: \(call.arguments)")
result(FlutterMethodNotImplemented)
return
}
// Requires 3 arguments in the array
guard args.count == 3 else {
print("Requires 3 arguments and received \(args.count)")
result(FlutterMethodNotImplemented)
return
}
// Parses the arguments
let callbackHandle = args[0] as? Int64
let notificationTitle = args[1] as? String
let instant = args[2] as? Bool
// Write enabled to settings
let defaults = UserDefaults.standard
// We are now enabled, so store this
defaults.set(true, forKey: "background_service_enabled")
// The callback handle is an int64 address to communicate with the main isolate's
// entry function
defaults.set(callbackHandle, forKey: "callback_handle")
// This is not used yet and will need to be implemented
defaults.set(notificationTitle, forKey: "notification_title")
// Schedule the background services
BackgroundServicePlugin.scheduleBackgroundSync()
BackgroundServicePlugin.scheduleBackgroundFetch()
result(true)
}
// Called by the flutter code at launch to see if the background service is enabled or not
func handleIsEnabled(call: FlutterMethodCall, result: FlutterResult) {
let defaults = UserDefaults.standard
let enabled = defaults.value(forKey: "background_service_enabled") as? Bool
// False by default
result(enabled ?? false)
}
// Called by the Flutter code whenever a change in configuration is set
func handleConfigure(call: FlutterMethodCall, result: FlutterResult) {
// Needs to be able to parse the arguments or else fail
guard let args = call.arguments as? Array<Any> else {
print("Cannot parse args as array: \(call.arguments)")
result(FlutterError())
return
}
// Needs to have 4 arguments in the call or else fail
guard args.count == 4 else {
print("Not enough arguments, 4 required: \(args.count) given")
result(FlutterError())
return
}
// Parse the arguments from the method call
let requireUnmeteredNetwork = args[0] as? Bool
let requireCharging = args[1] as? Bool
let triggerUpdateDelay = args[2] as? Int
let triggerMaxDelay = args[3] as? Int
// Store the values from the call in the defaults
let defaults = UserDefaults.standard
defaults.set(requireUnmeteredNetwork, forKey: "require_unmetered_network")
defaults.set(requireCharging, forKey: "require_charging")
defaults.set(triggerUpdateDelay, forKey: "trigger_update_delay")
defaults.set(triggerMaxDelay, forKey: "trigger_max_delay")
// Cancel the background services and reschedule them
BGTaskScheduler.shared.cancelAllTaskRequests()
BackgroundServicePlugin.scheduleBackgroundSync()
BackgroundServicePlugin.scheduleBackgroundFetch()
result(true)
}
// Returns the number of currently scheduled background processes to Flutter, strictly
// for debugging
func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) {
BGTaskScheduler.shared.getPendingTaskRequests { requests in
result(requests.count)
}
}
// Disables the service, cancels all the task requests
func handleDisable(call: FlutterMethodCall, result: FlutterResult) {
let defaults = UserDefaults.standard
defaults.set(false, forKey: "background_service_enabled")
BGTaskScheduler.shared.cancelAllTaskRequests()
result(true)
}
// Checks the status of the Background App Refresh from the system
// Returns true if the service is enabled for Immich, and false otherwise
func handleBackgroundRefreshStatus(call: FlutterMethodCall, result: FlutterResult) {
switch UIApplication.shared.backgroundRefreshStatus {
case .available:
result(true)
break
case .denied:
result(false)
break
case .restricted:
result(false)
break
default:
result(false)
break
}
}
// Schedules a short-running background sync to sync only a few photos
static func scheduleBackgroundFetch() {
// We will schedule this task to run no matter the charging or wifi requirents from the end user
// 1. They can set Background App Refresh to Off / Wi-Fi / Wi-Fi & Cellular Data from Settings
// 2. We will check the battery connectivity when we begin running the background activity
let backgroundFetch = BGAppRefreshTaskRequest(identifier: BackgroundServicePlugin.backgroundFetchTaskID)
// Use 5 minutes from now as earliest begin date
backgroundFetch.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)
do {
try BGTaskScheduler.shared.submit(backgroundFetch)
} catch {
print("Could not schedule the background task \(error.localizedDescription)")
}
}
// Schedules a long-running background sync for syncing all of the photos
static func scheduleBackgroundSync() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: BackgroundServicePlugin.backgroundProcessingTaskID)
// We need the values for requiring charging
let defaults = UserDefaults.standard
let requireCharging = defaults.value(forKey: "require_charging") as? Bool
// Always require network connectivity, and set the require charging from the above
backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.requiresExternalPower = requireCharging ?? true
// Use 15 minutes from now as earliest begin date
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
// Submit the task to the scheduler
try BGTaskScheduler.shared.submit(backgroundProcessing)
} catch {
print("Could not schedule the background task \(error.localizedDescription)")
}
}
// This function runs when the system kicks off the BGAppRefreshTask from the Background Task Scheduler
static func handleBackgroundFetch(task: BGAppRefreshTask) {
// Schedule the next sync task so we can run this again later
scheduleBackgroundFetch()
// Log the time of last background processing to now
let defaults = UserDefaults.standard
defaults.set(Date().timeIntervalSince1970, forKey: "last_background_fetch_run_time")
// If we have required charging, we should check the charging status
let requireCharging = defaults.value(forKey: "require_charging") as? Bool ?? false
if (requireCharging) {
UIDevice.current.isBatteryMonitoringEnabled = true
if (UIDevice.current.batteryState == .unplugged) {
// The device is unplugged and we have required charging
// Therefore, we will simply complete the task without
// running it.
task.setTaskCompleted(success: true)
return
}
}
// 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
}
}
// Schedule the next sync task so we can run this again later
scheduleBackgroundFetch()
// The background sync task should only run for 20 seconds at most
BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: 20)
}
// This function runs when the system kicks off the BGProcessingTask from the Background Task Scheduler
static func handleBackgroundProcessing(task: BGProcessingTask) {
// Schedule the next sync task so we run this again later
scheduleBackgroundSync()
// Log the time of last background processing to now
let defaults = UserDefaults.standard
defaults.set(Date().timeIntervalSince1970, forKey: "last_background_processing_run_time")
// We won't specify a max time for the background sync service, so this can run for longer
BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: nil)
}
// This is a synchronous function which uses a semaphore to run the background sync worker's run
// function, which will create a background Isolate and communicate with the Flutter code to back
// up the assets. When it completes, we signal the semaphore and complete the execution allowing the
// control to pass back to the caller synchronously
static func runBackgroundSync(_ task: BGTask, maxSeconds: Int?) {
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.main.async {
let backgroundWorker = BackgroundSyncWorker { _ in
semaphore.signal()
}
task.expirationHandler = {
backgroundWorker.cancel()
task.setTaskCompleted(success: true)
}
backgroundWorker.run(maxSeconds: maxSeconds)
task.setTaskCompleted(success: true)
}
semaphore.wait()
}
}

View file

@ -0,0 +1,271 @@
//
// BackgroundSyncProcessing.swift
// Runner
//
// Created by Marty Fuhry on 2/6/23.
//
// Credit to https://github.com/fluttercommunity/flutter_workmanager/blob/main/ios/Classes/BackgroundWorker.swift
import Foundation
import Flutter
import BackgroundTasks
// The background worker which creates a new Flutter VM, communicates with it
// to run the backup job, and then finishes execution and calls back to its callback
// handler
class BackgroundSyncWorker {
// The Flutter engine we create for background execution.
// This is not the main Flutter engine which shows the UI,
// this is a brand new isolate created and managed in this code
// here. It does not share memory with the main
// Flutter engine which shows the UI.
// It needs to be started up, registered, and torn down here
let engine: FlutterEngine? = FlutterEngine(
name: "BackgroundImmich"
)
let notificationId = "com.alextran.immich/backgroundNotifications"
// The background message passing channel
var channel: FlutterMethodChannel?
var completionHandler: (UIBackgroundFetchResult) -> Void
let taskSessionStart = Date()
// We need the completion handler to tell the system when we are done running
init(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// This is the background message passing channel to be used with the background engine
// created here in this platform code
self.channel = FlutterMethodChannel(
name: "immich/backgroundChannel",
binaryMessenger: engine!.binaryMessenger
)
self.completionHandler = completionHandler
}
// Handles all of the messages from the Flutter VM called into this platform code
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialized":
// Initialize tells us that we can now call into the Flutter VM to tell it to begin the update
self.channel?.invokeMethod(
"backgroundProcessing",
arguments: nil,
result: { flutterResult in
// This is the result we send back to the BGTaskScheduler to let it know whether we'll need more time later or
// if this execution failed
let result: UIBackgroundFetchResult = (flutterResult as? Bool ?? false) ? .newData : .failed
// Show the task duration
let taskSessionCompleter = Date()
let taskDuration = taskSessionCompleter.timeIntervalSince(self.taskSessionStart)
print("[\(String(describing: self))] \(#function) -> performBackgroundRequest.\(result) (finished in \(taskDuration) seconds)")
// Complete the execution
self.complete(result)
})
break
case "updateNotification":
let handled = self.handleNotification(call)
result(handled)
break
case "showError":
let handled = self.handleError(call)
result(handled)
break
case "clearErrorNotifications":
self.handleClearErrorNotifications()
result(true)
break
case "hasContentChanged":
// This is only called for Android, but we provide an implementation here
// telling Flutter that we don't have any information about whether the gallery
// contents have changed or not, so we can just say "no, they've not changed"
result(false)
break
default:
result(FlutterError())
self.complete(UIBackgroundFetchResult.failed)
}
}
// Runs the background sync by starting up a new isolate and handling the calls
// until it completes
public func run(maxSeconds: Int?) {
// We need the callback handle to start up the Flutter VM from the entry point
let defaults = UserDefaults.standard
guard let callbackHandle = defaults.value(forKey: "callback_handle") as? Int64 else {
// Can't find the callback handle, this is fatal
complete(UIBackgroundFetchResult.failed)
return
}
// Use the provided callbackHandle to get the callback function
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
// We need this callback or else this is fatal
complete(UIBackgroundFetchResult.failed)
return
}
// Sanity check for the engine existing
if engine == nil {
complete(UIBackgroundFetchResult.failed)
return
}
// Run the engine
let isRunning = engine!.run(
withEntrypoint: callback.callbackName,
libraryURI: callback.callbackLibraryPath
)
// If this engine isn't running, this is fatal
if !isRunning {
complete(UIBackgroundFetchResult.failed)
return
}
// If we have a timer, we need to start the timer to cancel ourselves
// so that we don't run longer than the provided maxSeconds
// After maxSeconds has elapsed, we will invoke "systemStop"
if maxSeconds != nil {
// Schedule a non-repeating timer to run after maxSeconds
let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!),
repeats: false) { timer in
// The callback invalidates the timer and stops execution
timer.invalidate()
// If the channel is already deallocated, we don't need to do anything
if self.channel == nil {
return
}
// Tell the Flutter VM to stop backing up now
self.channel?.invokeMethod(
"systemStop",
arguments: nil,
result: nil)
// Complete the execution
self.complete(UIBackgroundFetchResult.newData)
}
}
// Set the handle function to the channel message handler
self.channel?.setMethodCallHandler(handle)
// Register this to get access to the plugins on the platform channel
BackgroundServicePlugin.flutterPluginRegistrantCallback?(engine!)
}
// Cancels execution of this task, used by the system's task expiration handler
// which is called shortly before execution is about to expire
public func cancel() {
// If the channel is already deallocated, we don't need to do anything
if self.channel == nil {
return
}
// Tell the Flutter VM to stop backing up now
self.channel?.invokeMethod(
"systemStop",
arguments: nil,
result: nil)
// Complete the execution
self.complete(UIBackgroundFetchResult.newData)
}
// Completes the execution, destroys the engine, and sends a completion to our callback completionHandler
private func complete(_ fetchResult: UIBackgroundFetchResult) {
engine?.destroyContext()
channel = nil
completionHandler(fetchResult)
}
private func handleNotification(_ call: FlutterMethodCall) -> Bool {
// Parse the arguments as an array list
guard let args = call.arguments as? Array<Any> else {
print("Failed to parse \(call.arguments) as array")
return false;
}
// Requires 7 arguments passed or else fail
guard args.count == 7 else {
print("Needs 7 arguments, but was only passed \(args.count)")
return false
}
// Parse the arguments to send the notification update
let title = args[0] as? String
let content = args[1] as? String
let progress = args[2] as? Int
let maximum = args[3] as? Int
let indeterminate = args[4] as? Bool
let isDetail = args[5] as? Bool
let onlyIfForeground = args[6] as? Bool
// Build the notification
let notificationContent = UNMutableNotificationContent()
notificationContent.body = content ?? "Uploading..."
notificationContent.title = title ?? "Immich"
// Add it to the Notification center
let notification = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: nil
)
let center = UNUserNotificationCenter.current()
center.add(notification) { (error: Error?) in
if let theError = error {
print("Error showing notifications: \(theError)")
}
}
return true
}
private func handleError(_ call: FlutterMethodCall) -> Bool {
// Parse the arguments as an array list
guard let args = call.arguments as? Array<Any> else {
return false;
}
// Requires 7 arguments passed or else fail
guard args.count == 3 else {
return false
}
let title = args[0] as? String
let content = args[1] as? String
let individualTag = args[2] as? String
// Build the notification
let notificationContent = UNMutableNotificationContent()
notificationContent.body = content ?? "Error running the backup job."
notificationContent.title = title ?? "Immich"
// Add it to the Notification center
let notification = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: nil
)
let center = UNUserNotificationCenter.current()
center.add(notification)
return true
}
private func handleClearErrorNotifications() {
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [notificationId])
center.removePendingNotificationRequests(withIdentifiers: [notificationId])
}
}

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="320" height="320"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="68" y="-2"/>
</scene>
</scenes>
</document>

View file

@ -0,0 +1,129 @@
// 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?
}
enum NetworkCapability: Int {
case cellular = 0
case wifi = 1
case vpn = 2
case unmetered = 3
}
private class ConnectivityPigeonCodecReader: 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 NetworkCapability(rawValue: enumResultAsInt)
}
return nil
default:
return super.readValue(ofType: type)
}
}
}
private class ConnectivityPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? NetworkCapability {
super.writeByte(129)
super.writeValue(value.rawValue)
} else {
super.writeValue(value)
}
}
}
private class ConnectivityPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return ConnectivityPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return ConnectivityPigeonCodecWriter(data: data)
}
}
class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ConnectivityApi {
func getCapabilities() throws -> [NetworkCapability]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ConnectivityApiSetup {
static var codec: FlutterStandardMessageCodec { ConnectivityPigeonCodec.shared }
/// Sets up an instance of `ConnectivityApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ConnectivityApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
#if os(iOS)
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
#else
let taskQueue: FlutterTaskQueue? = nil
#endif
let getCapabilitiesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCapabilitiesChannel.setMessageHandler { _, reply in
do {
let result = try api.getCapabilities()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCapabilitiesChannel.setMessageHandler(nil)
}
}
}

View file

@ -0,0 +1,60 @@
import Network
class ConnectivityApiImpl: ConnectivityApi {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "ConnectivityMonitor")
private var currentPath: NWPath?
init() {
monitor.pathUpdateHandler = { [weak self] path in
self?.currentPath = path
}
monitor.start(queue: queue)
// Get initial state synchronously
currentPath = monitor.currentPath
}
deinit {
monitor.cancel()
}
func getCapabilities() throws -> [NetworkCapability] {
guard let path = currentPath else {
return []
}
guard path.status == .satisfied else {
return []
}
var capabilities: [NetworkCapability] = []
if path.usesInterfaceType(.wifi) {
capabilities.append(.wifi)
}
if path.usesInterfaceType(.cellular) {
capabilities.append(.cellular)
}
// Check for VPN - iOS reports VPN as .other interface type in many cases
// or through the path's expensive property when on cellular with VPN
if path.usesInterfaceType(.other) {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}
return capabilities
}
}

View file

@ -0,0 +1,17 @@
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)
}
}

View file

@ -0,0 +1,138 @@
// 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?
}
private class LocalImagesPigeonCodecReader: FlutterStandardReader {
}
private class LocalImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return LocalImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return LocalImagesPigeonCodecWriter(data: data)
}
}
class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class LocalImageApiSetup {
static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared }
/// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let requestIdArg = args[1] as! Int64
let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64
let isVideoArg = args[4] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelRequestChannel.setMessageHandler(nil)
}
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getThumbhashChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let thumbhashArg = args[0] as! String
api.getThumbhash(thumbhash: thumbhashArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getThumbhashChannel.setMessageHandler(nil)
}
}
}

View file

@ -0,0 +1,182 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class LocalImageRequest {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
}
class LocalImageApiImpl: LocalImageApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = 1
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}()
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
}
Self.concurrencySemaphore.wait()
defer {
Self.concurrencySemaphore.signal()
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(Self.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.workItem = item
Self.add(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(Self.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
}

View file

@ -0,0 +1,134 @@
// 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?
}
private class RemoteImagesPigeonCodecReader: FlutterStandardReader {
}
private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return RemoteImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return RemoteImagesPigeonCodecWriter(data: data)
}
}
class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class RemoteImageApiSetup {
static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared }
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelRequestChannel.setMessageHandler(nil)
}
let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
clearCacheChannel.setMessageHandler { _, reply in
api.clearCache { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
clearCacheChannel.setMessageHandler(nil)
}
}
}

View file

@ -0,0 +1,186 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class RemoteImageRequest {
weak var task: URLSessionDataTask?
let id: Int64
var isCancelled = false
var data: CFMutableData?
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
self.data = nil
self.completion = completion
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let delegate = RemoteImageApiDelegate()
static let session = {
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1 << 30,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 64
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}()
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = Self.session.dataTask(with: urlRequest)
let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion)
Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest)
task.resume()
}
func cancelRequest(requestId: Int64) {
Self.delegate.cancel(requestId: requestId)
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = Self.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .perceptual
)!
private static var requestByTaskId = [Int: RemoteImageRequest]()
private static var taskIdByRequestId = [Int64: Int]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let decodeOptions = [
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let request = get(taskId: dataTask.taskIdentifier)
else {
return completionHandler(.cancel)
}
let capacity = max(Int(response.expectedContentLength), 0)
request.data = CFDataCreateMutable(nil, capacity)
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive data: Data) {
guard let request = get(taskId: dataTask.taskIdentifier) else { return }
data.withUnsafeBytes { bytes in
CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let request = get(taskId: task.taskIdentifier) else { return }
defer { remove(taskId: task.taskIdentifier, requestId: request.id) }
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(Self.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
}
guard let data = request.data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
guard let imageSource = CGImageSourceCreateWithData(data, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(Self.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
}
}
@inline(__always) func get(taskId: Int) -> RemoteImageRequest? {
Self.requestQueue.sync { Self.requestByTaskId[taskId] }
}
@inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.requestByTaskId[taskId] = request
Self.taskIdByRequestId[request.id] = taskId
}
}
@inline(__always) func remove(taskId: Int, requestId: Int64) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.taskIdByRequestId[requestId] = nil
Self.requestByTaskId[taskId] = nil
}
}
@inline(__always) func cancel(requestId: Int64) -> Void {
guard let request: RemoteImageRequest = (Self.requestQueue.sync {
guard let taskId = Self.taskIdByRequestId[requestId] else { return nil }
return Self.requestByTaskId[taskId]
}) else { return }
request.isCancelled = true
request.task?.cancel()
}
}

View file

@ -0,0 +1,225 @@
// Copyright (c) 2023 Evan Wallace
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import Foundation
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// changed signature and allocation method to avoid automatic GC
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
let pointer = UnsafeMutableRawBufferPointer.allocate(
byteCount: w * h * 4,
alignment: MemoryLayout<UInt8>.alignment
)
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
return (w, h, pointer)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}

View file

@ -0,0 +1,188 @@
<?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>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>240</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View file

@ -0,0 +1,16 @@
<?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>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,18 @@
<?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>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,177 @@
import SQLiteData
struct Endpoint: Codable {
let url: URL
let status: Status
enum Status: String, Codable {
case loading, valid, error, unknown
}
}
enum StoreKey: Int, CaseIterable, QueryBindable {
// MARK: - Int
case _version = 0
static let version = Typed<Int>(rawValue: ._version)
case _deviceIdHash = 3
static let deviceIdHash = Typed<Int>(rawValue: ._deviceIdHash)
case _backupTriggerDelay = 8
static let backupTriggerDelay = Typed<Int>(rawValue: ._backupTriggerDelay)
case _tilesPerRow = 103
static let tilesPerRow = Typed<Int>(rawValue: ._tilesPerRow)
case _groupAssetsBy = 105
static let groupAssetsBy = Typed<Int>(rawValue: ._groupAssetsBy)
case _uploadErrorNotificationGracePeriod = 106
static let uploadErrorNotificationGracePeriod = Typed<Int>(rawValue: ._uploadErrorNotificationGracePeriod)
case _thumbnailCacheSize = 110
static let thumbnailCacheSize = Typed<Int>(rawValue: ._thumbnailCacheSize)
case _imageCacheSize = 111
static let imageCacheSize = Typed<Int>(rawValue: ._imageCacheSize)
case _albumThumbnailCacheSize = 112
static let albumThumbnailCacheSize = Typed<Int>(rawValue: ._albumThumbnailCacheSize)
case _selectedAlbumSortOrder = 113
static let selectedAlbumSortOrder = Typed<Int>(rawValue: ._selectedAlbumSortOrder)
case _logLevel = 115
static let logLevel = Typed<Int>(rawValue: ._logLevel)
case _mapRelativeDate = 119
static let mapRelativeDate = Typed<Int>(rawValue: ._mapRelativeDate)
case _mapThemeMode = 124
static let mapThemeMode = Typed<Int>(rawValue: ._mapThemeMode)
// MARK: - String
case _assetETag = 1
static let assetETag = Typed<String>(rawValue: ._assetETag)
case _currentUser = 2
static let currentUser = Typed<String>(rawValue: ._currentUser)
case _deviceId = 4
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
static let sslClientPasswd = Typed<String>(rawValue: ._sslClientPasswd)
case _themeMode = 102
static let themeMode = Typed<String>(rawValue: ._themeMode)
case _customHeaders = 127
static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
case _primaryColor = 128
static let primaryColor = Typed<String>(rawValue: ._primaryColor)
case _preferredWifiName = 133
static let preferredWifiName = Typed<String>(rawValue: ._preferredWifiName)
// MARK: - Endpoint
case _externalEndpointList = 135
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)
// MARK: - Date
case _backupFailedSince = 5
static let backupFailedSince = Typed<Date>(rawValue: ._backupFailedSince)
// MARK: - Bool
case _backupRequireWifi = 6
static let backupRequireWifi = Typed<Bool>(rawValue: ._backupRequireWifi)
case _backupRequireCharging = 7
static let backupRequireCharging = Typed<Bool>(rawValue: ._backupRequireCharging)
case _autoBackup = 13
static let autoBackup = Typed<Bool>(rawValue: ._autoBackup)
case _backgroundBackup = 14
static let backgroundBackup = Typed<Bool>(rawValue: ._backgroundBackup)
case _loadPreview = 100
static let loadPreview = Typed<Bool>(rawValue: ._loadPreview)
case _loadOriginal = 101
static let loadOriginal = Typed<Bool>(rawValue: ._loadOriginal)
case _dynamicLayout = 104
static let dynamicLayout = Typed<Bool>(rawValue: ._dynamicLayout)
case _backgroundBackupTotalProgress = 107
static let backgroundBackupTotalProgress = Typed<Bool>(rawValue: ._backgroundBackupTotalProgress)
case _backgroundBackupSingleProgress = 108
static let backgroundBackupSingleProgress = Typed<Bool>(rawValue: ._backgroundBackupSingleProgress)
case _storageIndicator = 109
static let storageIndicator = Typed<Bool>(rawValue: ._storageIndicator)
case _advancedTroubleshooting = 114
static let advancedTroubleshooting = Typed<Bool>(rawValue: ._advancedTroubleshooting)
case _preferRemoteImage = 116
static let preferRemoteImage = Typed<Bool>(rawValue: ._preferRemoteImage)
case _loopVideo = 117
static let loopVideo = Typed<Bool>(rawValue: ._loopVideo)
case _mapShowFavoriteOnly = 118
static let mapShowFavoriteOnly = Typed<Bool>(rawValue: ._mapShowFavoriteOnly)
case _selfSignedCert = 120
static let selfSignedCert = Typed<Bool>(rawValue: ._selfSignedCert)
case _mapIncludeArchived = 121
static let mapIncludeArchived = Typed<Bool>(rawValue: ._mapIncludeArchived)
case _ignoreIcloudAssets = 122
static let ignoreIcloudAssets = Typed<Bool>(rawValue: ._ignoreIcloudAssets)
case _selectedAlbumSortReverse = 123
static let selectedAlbumSortReverse = Typed<Bool>(rawValue: ._selectedAlbumSortReverse)
case _mapwithPartners = 125
static let mapwithPartners = Typed<Bool>(rawValue: ._mapwithPartners)
case _enableHapticFeedback = 126
static let enableHapticFeedback = Typed<Bool>(rawValue: ._enableHapticFeedback)
case _dynamicTheme = 129
static let dynamicTheme = Typed<Bool>(rawValue: ._dynamicTheme)
case _colorfulInterface = 130
static let colorfulInterface = Typed<Bool>(rawValue: ._colorfulInterface)
case _syncAlbums = 131
static let syncAlbums = Typed<Bool>(rawValue: ._syncAlbums)
case _autoEndpointSwitching = 132
static let autoEndpointSwitching = Typed<Bool>(rawValue: ._autoEndpointSwitching)
case _loadOriginalVideo = 136
static let loadOriginalVideo = Typed<Bool>(rawValue: ._loadOriginalVideo)
case _manageLocalMediaAndroid = 137
static let manageLocalMediaAndroid = Typed<Bool>(rawValue: ._manageLocalMediaAndroid)
case _readonlyModeEnabled = 138
static let readonlyModeEnabled = Typed<Bool>(rawValue: ._readonlyModeEnabled)
case _autoPlayVideo = 139
static let autoPlayVideo = Typed<Bool>(rawValue: ._autoPlayVideo)
case _photoManagerCustomFilter = 1000
static let photoManagerCustomFilter = Typed<Bool>(rawValue: ._photoManagerCustomFilter)
case _betaPromptShown = 1001
static let betaPromptShown = Typed<Bool>(rawValue: ._betaPromptShown)
case _betaTimeline = 1002
static let betaTimeline = Typed<Bool>(rawValue: ._betaTimeline)
case _enableBackup = 1003
static let enableBackup = Typed<Bool>(rawValue: ._enableBackup)
case _useWifiForUploadVideos = 1004
static let useWifiForUploadVideos = Typed<Bool>(rawValue: ._useWifiForUploadVideos)
case _useWifiForUploadPhotos = 1005
static let useWifiForUploadPhotos = Typed<Bool>(rawValue: ._useWifiForUploadPhotos)
case _needBetaMigration = 1006
static let needBetaMigration = Typed<Bool>(rawValue: ._needBetaMigration)
case _shouldResetSync = 1007
static let shouldResetSync = Typed<Bool>(rawValue: ._shouldResetSync)
struct Typed<T>: RawRepresentable {
let rawValue: StoreKey
@_transparent
init(rawValue value: StoreKey) {
self.rawValue = value
}
}
}
enum BackupSelection: Int, QueryBindable {
case selected, none, excluded
}
enum AvatarColor: Int, QueryBindable {
case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
}
enum AlbumUserRole: Int, QueryBindable {
case editor, viewer
}
enum MemoryType: Int, QueryBindable {
case onThisDay
}

View file

@ -0,0 +1,146 @@
import SQLiteData
enum StoreError: Error {
case invalidJSON(String)
case invalidURL(String)
case encodingFailed
}
protocol StoreConvertible {
associatedtype StorageType
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
static func toValue(_ value: Self) throws(StoreError) -> StorageType
}
extension Int: StoreConvertible {
static func fromValue(_ value: Int) -> Int { value }
static func toValue(_ value: Int) -> Int { value }
}
extension Bool: StoreConvertible {
static func fromValue(_ value: Int) -> Bool { value == 1 }
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
}
extension Date: StoreConvertible {
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 func fromValue(_ value: String) -> String { value }
static func toValue(_ value: String) -> String { value }
}
extension URL: StoreConvertible {
static func fromValue(_ value: String) throws(StoreError) -> URL {
guard let url = URL(string: value) else {
throw StoreError.invalidURL(value)
}
return url
}
static func toValue(_ value: URL) -> String { value.absoluteString }
}
extension StoreConvertible where Self: Codable, StorageType == String {
static var jsonDecoder: JSONDecoder { JSONDecoder() }
static var jsonEncoder: JSONEncoder { JSONEncoder() }
static func fromValue(_ value: String) throws(StoreError) -> Self {
do {
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
} catch {
throw StoreError.invalidJSON(value)
}
}
static func toValue(_ value: Self) throws(StoreError) -> String {
let encoded: Data
do {
encoded = try jsonEncoder.encode(value)
} catch {
throw StoreError.encodingFailed
}
guard let string = String(data: encoded, encoding: .utf8) else {
throw StoreError.encodingFailed
}
return string
}
}
extension Array: StoreConvertible where Element: Codable {
typealias StorageType = String
}
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
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 {
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)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
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)
}
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
}
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)
}
}
}

View file

@ -0,0 +1,237 @@
import GRDB
import SQLiteData
@Table("asset_face_entity")
struct AssetFace {
let id: String
let assetId: String
let personId: String?
let imageWidth: Int
let imageHeight: Int
let boundingBoxX1: Int
let boundingBoxY1: Int
let boundingBoxX2: Int
let boundingBoxY2: Int
let sourceType: String
}
@Table("auth_user_entity")
struct AuthUser {
let id: String
let name: String
let email: String
let isAdmin: Bool
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
let quotaSizeInBytes: Int
let quotaUsageInBytes: Int
let pinCode: String?
}
@Table("local_album_entity")
struct LocalAlbum {
let id: String
let backupSelection: BackupSelection
let linkedRemoteAlbumId: String?
let marker_: Bool?
let name: String
let isIosSharedAlbum: Bool
let updatedAt: Date
}
@Table("local_album_asset_entity")
struct LocalAlbumAsset {
let id: ID
let marker_: String?
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("local_asset_entity")
struct LocalAsset {
let id: String
let checksum: String?
let createdAt: Date
let durationInSeconds: Int?
let height: Int?
let isFavorite: Bool
let name: String
let orientation: String
let type: Int
let updatedAt: Date
let width: Int?
}
@Table("memory_asset_entity")
struct MemoryAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("memory_entity")
struct Memory {
let id: String
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
let ownerId: String
let type: MemoryType
let data: String
let isSaved: Bool
let memoryAt: Date
let seenAt: Date?
let showAt: Date?
let hideAt: Date?
}
@Table("partner_entity")
struct Partner {
let id: ID
let inTimeline: Bool
@Selection
struct ID {
let sharedById: String
let sharedWithId: String
}
}
@Table("person_entity")
struct Person {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let name: String
let faceAssetId: String?
let isFavorite: Bool
let isHidden: Bool
let color: String?
let birthDate: Date?
}
@Table("remote_album_entity")
struct RemoteAlbum {
let id: String
let createdAt: Date
let description: String?
let isActivityEnabled: Bool
let name: String
let order: Int
let ownerId: String
let thumbnailAssetId: String?
let updatedAt: Date
}
@Table("remote_album_asset_entity")
struct RemoteAlbumAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("remote_album_user_entity")
struct RemoteAlbumUser {
let id: ID
let role: AlbumUserRole
@Selection
struct ID {
let albumId: String
let userId: String
}
}
@Table("remote_asset_entity")
struct RemoteAsset {
let id: String
let checksum: String?
let deletedAt: Date?
let isFavorite: Int
let libraryId: String?
let livePhotoVideoId: String?
let localDateTime: Date?
let orientation: String
let ownerId: String
let stackId: String?
let visibility: Int
}
@Table("remote_exif_entity")
struct RemoteExif {
@Column(primaryKey: true)
let assetId: String
let city: String?
let state: String?
let country: String?
let dateTimeOriginal: Date?
let description: String?
let height: Int?
let width: Int?
let exposureTime: String?
let fNumber: Double?
let fileSize: Int?
let focalLength: Double?
let latitude: Double?
let longitude: Double?
let iso: Int?
let make: String?
let model: String?
let lens: String?
let orientation: String?
let timeZone: String?
let rating: Int?
let projectionType: String?
}
@Table("stack_entity")
struct Stack {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let primaryAssetId: String
}
@Table("store_entity")
struct Store {
let id: StoreKey
let stringValue: String?
let intValue: Int?
}
@Table("user_entity")
struct User {
let id: String
let name: String
let email: String
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
}
@Table("user_metadata_entity")
struct UserMetadata {
let id: ID
let value: Data
@Selection
struct ID {
let userId: String
let key: Date
}
}

View file

@ -0,0 +1,620 @@
// 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
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
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 deepEqualsMessages(_ 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 !deepEqualsMessages(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 !deepEqualsMessages(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 deepHashMessages(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashMessages(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashMessages(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
var name: String
var type: Int64
var createdAt: Int64? = nil
var updatedAt: Int64? = nil
var width: Int64? = nil
var height: Int64? = nil
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAsset? {
let id = pigeonVar_list[0] as! String
let name = pigeonVar_list[1] as! String
let type = pigeonVar_list[2] as! Int64
let createdAt: Int64? = nilOrValue(pigeonVar_list[3])
let updatedAt: Int64? = nilOrValue(pigeonVar_list[4])
let width: Int64? = nilOrValue(pigeonVar_list[5])
let height: Int64? = nilOrValue(pigeonVar_list[6])
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
return PlatformAsset(
id: id,
name: name,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
width: width,
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude
)
}
func toList() -> [Any?] {
return [
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAlbum: Hashable {
var id: String
var name: String
var updatedAt: Int64? = nil
var isCloud: Bool
var assetCount: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAlbum? {
let id = pigeonVar_list[0] as! String
let name = pigeonVar_list[1] as! String
let updatedAt: Int64? = nilOrValue(pigeonVar_list[2])
let isCloud = pigeonVar_list[3] as! Bool
let assetCount = pigeonVar_list[4] as! Int64
return PlatformAlbum(
id: id,
name: name,
updatedAt: updatedAt,
isCloud: isCloud,
assetCount: assetCount
)
}
func toList() -> [Any?] {
return [
id,
name,
updatedAt,
isCloud,
assetCount,
]
}
static func == (lhs: PlatformAlbum, rhs: PlatformAlbum) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct SyncDelta: Hashable {
var hasChanges: Bool
var updates: [PlatformAsset]
var deletes: [String]
var assetAlbums: [String: [String]]
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? {
let hasChanges = pigeonVar_list[0] as! Bool
let updates = pigeonVar_list[1] as! [PlatformAsset]
let deletes = pigeonVar_list[2] as! [String]
let assetAlbums = pigeonVar_list[3] as! [String: [String]]
return SyncDelta(
hasChanges: hasChanges,
updates: updates,
deletes: deletes,
assetAlbums: assetAlbums
)
}
func toList() -> [Any?] {
return [
hasChanges,
updates,
deletes,
assetAlbums,
]
}
static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct HashResult: Hashable {
var assetId: String
var error: String? = nil
var hash: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let hash: String? = nilOrValue(pigeonVar_list[2])
return HashResult(
assetId: assetId,
error: error,
hash: hash
)
}
func toList() -> [Any?] {
return [
assetId,
error,
hash,
]
}
static func == (lhs: HashResult, rhs: HashResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct CloudIdResult: Hashable {
var assetId: String
var error: String? = nil
var cloudId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let cloudId: String? = nilOrValue(pigeonVar_list[2])
return CloudIdResult(
assetId: assetId,
error: error,
cloudId: cloudId
)
}
func toList() -> [Any?] {
return [
assetId,
error,
cloudId,
]
}
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 130:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PlatformAsset {
super.writeByte(129)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return MessagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return MessagesPigeonCodecWriter(data: data)
}
}
class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws
func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class NativeSyncApiSetup {
static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared }
/// Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeSyncApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
#if os(iOS)
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
#else
let taskQueue: FlutterTaskQueue? = nil
#endif
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
do {
let result = try api.shouldFullSync()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getMediaChangesChannel.setMessageHandler(nil)
}
let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
checkpointSyncChannel.setMessageHandler { _, reply in
do {
try api.checkpointSync()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
checkpointSyncChannel.setMessageHandler(nil)
}
let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
clearSyncCheckpointChannel.setMessageHandler { _, reply in
do {
try api.clearSyncCheckpoint()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetIdsForAlbumChannel.setMessageHandler(nil)
}
let getAlbumsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAlbumsChannel.setMessageHandler(nil)
}
let getAssetsCountSinceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetsCountSinceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let timestampArg = args[1] as! Int64
do {
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetsForAlbumChannel.setMessageHandler(nil)
}
let hashAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
hashAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
let allowNetworkAccessArg = args[1] as! Bool
api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
hashAssetsChannel.setMessageHandler(nil)
}
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelHashingChannel.setMessageHandler { _, reply in
do {
try api.cancelHashing()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelHashingChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getTrashedAssetsChannel.setMessageHandler { _, reply in
do {
let result = try api.getTrashedAssets()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
}
}

View file

@ -0,0 +1,417 @@
import Photos
import CryptoKit
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
init(with asset: PlatformAsset) {
self.asset = asset
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.asset.id)
}
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
return lhs.asset.id == rhs.asset.id
}
}
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
static let name = "NativeSyncApi"
static func register(with registrar: any FlutterPluginRegistrar) {
let instance = NativeSyncApiImpl()
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
registrar.publish(instance)
}
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
super.detachFromEngine()
}
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
private var hashTask: Task<Void?, Error>?
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
}
@available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? {
guard let data = defaults.data(forKey: changeTokenKey) else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
@available(iOS 16, *)
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
return
}
defaults.set(data, forKey: changeTokenKey)
}
func clearSyncCheckpoint() -> Void {
defaults.removeObject(forKey: changeTokenKey)
}
func checkpointSync() {
guard #available(iOS 16, *) else {
return
}
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else {
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
return true
}
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
// Cannot fetch persistent changes
return true
}
return false
}
func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
let album = collections.object(at: i)
// Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue;
}
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
var domainAlbum = PlatformAlbum(
id: album.localIdentifier,
name: album.localizedTitle!,
updatedAt: nil,
isCloud: isCloud,
assetCount: Int64(assets.count)
)
if let firstAsset = assets.firstObject {
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
}
albums.append(domainAlbum)
}
}
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
}
guard let storedToken = getChangeToken() else {
// No token exists, definitely need a full sync
print("MediaManager::getMediaChanges: No token found")
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
}
let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationInSeconds: 0,
orientation: 0,
isFavorite: false
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
}
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
}
var albumAssets: [String: [String]] = [:]
for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
options.includeHiddenAssets = false
let result = self.getAssetsFromAlbum(in: album, options: options)
result.enumerateObjects { (asset, _, _) in
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
}
}
}
return albumAssets
}
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
var ids: [String] = []
let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
assets.enumerateObjects { (asset, _, _) in
ids.append(asset.localIdentifier)
}
return ids
}
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return 0
}
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
let options = PHFetchOptions()
options.includeHiddenAssets = false
if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
}
let result = getAssetsFromAlbum(in: album, options: options)
if(result.count == 0) {
return []
}
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset())
}
return assets
}
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask {
prevTask.cancel()
hashTask = nil
}
hashTask = Task { [weak self] in
var missingAssetIds = Set(assetIds)
var assets = [PHAsset]()
assets.reserveCapacity(assetIds.count)
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
missingAssetIds.remove(asset.localIdentifier)
assets.append(asset)
}
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]()
results.reserveCapacity(assets.count)
for asset in assets {
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
taskGroup.addTask {
guard let self = self else { return nil }
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
}
}
for await result in taskGroup {
guard let result = result else {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
results.append(result)
}
for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
}
return self?.completeWhenActive(for: completion, with: .success(results))
}
}
}
func cancelHashing() {
hashTask?.cancel()
hashTask = nil
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
}
let requestRef = RequestRef()
return await withTaskCancellationHandler(operation: {
if Task.isCancelled {
return nil
}
guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
}
if Task.isCancelled {
return nil
}
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1()
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in
hasher.update(data: data)
},
completionHandler: { error in
let result: HashResult? = switch (error) {
case let e as PHPhotosError where e.code == .userCancelled: nil
case let .some(e): HashResult(
assetId: asset.localIdentifier,
error: "Failed to hash asset: \(e.localizedDescription)",
hash: nil
)
case .none:
HashResult(
assetId: asset.localIdentifier,
error: nil,
hash: Data(hasher.finalize()).base64EncodedString()
)
}
continuation.resume(returning: result)
}
)
}
}, onCancel: {
guard let requestId = requestRef.id else { return }
PHAssetResourceManager.default().cancelDataRequest(requestId)
})
}
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
return PHAsset.fetchAssets(with: options)
} else {
return PHAsset.fetchAssets(in: album, options: options)
}
}
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
guard #available(iOS 16, *) else {
return assetIds.map { CloudIdResult(assetId: $0) }
}
var mappings: [CloudIdResult] = []
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
switch value {
case .success(let cloudIdentifier):
let cloudId = cloudIdentifier.stringValue
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if !cloudId.hasSuffix(":") {
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
} else {
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
}
case .failure(let error):
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
}
}
return mappings;
}
}

View file

@ -0,0 +1,87 @@
import Photos
extension PHAsset {
func toPlatformAsset() -> PlatformAsset {
return PlatformAsset(
id: localIdentifier,
name: title,
type: Int64(mediaType.rawValue),
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude
)
}
var title: String {
return filename ?? originalFilename ?? "<unknown>"
}
var filename: String? {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
}
return nil
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? {
return getResource()?.originalFilename
}
func getResource() -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: self)
let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
guard !filteredResources.isEmpty else {
return nil
}
if filteredResources.count == 1 {
return filteredResources.first
}
if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
return currentResource
}
if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
return fullSizeResource
}
return nil
}
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
switch mediaType {
case .image:
return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
case .video:
return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
default:
return false
}
}
private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
switch mediaType {
case .image:
return type == .fullSizePhoto
case .video:
return type == .fullSizeVideo
default:
return false
}
}
}

View file

@ -0,0 +1,16 @@
import Photos
extension PHAssetResource {
var isCurrent: Bool {
return value(forKey: "isCurrent") as? Bool ?? false
}
var isMediaResource: Bool {
var isMedia = type != .adjustmentData
if #available(iOS 17, *) {
isMedia = isMedia && type != .photoProxy
}
return isMedia
}
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View file

@ -0,0 +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,
$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 &gt; 0
).@count &gt; 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>

View file

@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,3 @@
import share_handler_ios_models
class ShareViewController: ShareHandlerIosViewController {}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,148 @@
import SwiftUI
import WidgetKit
typealias EntryMetadata = ImageEntry.Metadata
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
}
static func build(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> Self
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return Self(
date: entryDate,
image: image,
metadata: EntryMetadata(
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
timelineEntry = cachedEntry
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
filter: SearchFilter = Album.NONE.filter,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let randomAssets = try await api.fetchSearchResults(with: filter)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await ImageEntry.build(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View file

@ -0,0 +1,72 @@
import SwiftUI
import WidgetKit
extension Image {
@ViewBuilder
func tintedWidgetImageModifier() -> some View {
if #available(iOS 18.0, *) {
self
.widgetAccentedRenderingMode(.accentedDesaturated)
} else {
self
}
}
}
struct ImmichWidgetView: View {
var entry: ImageEntry
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.metadata.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
.background(Color.black.opacity(0.6))
.cornerRadius(8)
.font(.system(size: 16))
}
}
.padding(16)
}
.widgetURL(entry.metadata.deepLink)
}
}
}
#Preview(
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "ImmichLogo"),
metadata: EntryMetadata(
subtitle: "1 year ago"
)
)
}
)

View file

@ -0,0 +1,313 @@
import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
case noLogin
case fetchFailed
case albumNotFound
case noAssetsAvailable
}
enum FetchError: Error {
case unableToResize
case invalidImage
case invalidURL
case fetchFailed
}
extension WidgetError: LocalizedError {
public var errorDescription: String? {
switch self {
case .noLogin:
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to your Immich instance"
case .albumNotFound:
return "Album not found"
case .noAssetsAvailable:
return "No assets available"
}
}
}
enum AssetType: String, Codable {
case image = "IMAGE"
case video = "VIDEO"
case audio = "AUDIO"
case other = "OTHER"
}
struct Asset: Codable {
let id: String
let type: AssetType
var deepLink: URL? {
return URL(string: "immich://asset?id=\(id)")
}
}
struct SearchFilter: Codable {
var type = AssetType.image
var size = 1
var albumIds: [String] = []
var isFavorite: Bool? = nil
}
struct MemoryResult: Codable {
let id: String
var assets: [Asset]
let type: String
struct MemoryData: Codable {
let year: Int
}
let data: MemoryData
}
struct Album: Codable, Equatable {
let id: String
let albumName: String
static let NONE = Album(id: "NONE", albumName: "None")
static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
var filter: SearchFilter {
switch self {
case Album.NONE:
return SearchFilter()
case Album.FAVORITES:
return SearchFilter(isFavorite: true)
// regular album
default:
return SearchFilter(albumIds: [id])
}
}
var isVirtual: Bool {
switch self {
case Album.NONE, Album.FAVORITES:
return true
default:
return false
}
}
}
// MARK: API
class ImmichAPI {
typealias CustomHeaders = [String:String]
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
let customHeaders: CustomHeaders
}
let serverConfig: ServerConfig
init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
throw WidgetError.noLogin
}
if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin
}
// custom headers come in the form of KV pairs in JSON
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
var customHeaders: CustomHeaders = [:]
if customHeadersJSON != "",
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
customHeaders = parsedHeaders
}
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey,
customHeaders: customHeaders
)
}
private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String,
params: [URLQueryItem] = []
) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
fatalError("Invalid base URL")
}
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
)
// Add the session key as a query parameter
var components = URLComponents(
url: fullPath,
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
]
components?.queryItems?.append(contentsOf: params)
return components?.url
}
func applyCustomHeaders(for request: inout URLRequest) {
for (header, value) in serverConfig.customHeaders {
request.addValue(value, forHTTPHeaderField: header)
}
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
{
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/search/random"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([Asset].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/memories",
params: memoryParams
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
let fetchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: assetEndpoint,
params: thumbnailParams
)
else {
throw .invalidURL
}
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL
}
let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
imageSource,
0,
decodeOptions as CFDictionary
)
else {
throw .fetchFailed
}
return UIImage(cgImage: thumbnail)
}
func fetchAlbums() async throws -> [Album] {
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/albums"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([Album].self, from: data)
}
}
// We need a shared cache for albums to efficiently handle the album picker queries
actor AlbumCache {
static let shared = AlbumCache()
private var api: ImmichAPI? = nil
private var albums: [Album]? = nil
func getAlbums(refresh: Bool = false) async throws -> [Album] {
// Check the API before we try to show cached albums
// Sometimes iOS caches this object and keeps it around
// even after nuking the timeline
api = try? await ImmichAPI()
guard api != nil else {
throw WidgetError.noLogin
}
if let albums, !refresh {
return albums
}
let fetched = try await api!.fetchAlbums()
albums = fetched
return fetched
}
}

View file

@ -0,0 +1,16 @@
<?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>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,23 @@
//
// Utils.swift
// Runner
//
// Created by Alex Tran and Brandon Wees on 6/16/25.
//
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(
width: width,
height: CGFloat(ceil(width / size.width * size.height))
)
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}

View file

@ -0,0 +1,10 @@
import SwiftUI
import WidgetKit
@main
struct ImmichWidgetBundle: WidgetBundle {
var body: some Widget {
ImmichRandomWidget()
ImmichMemoryWidget()
}
}

View file

@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show more