diff --git a/mobile/native/.gitignore b/mobile/native/.gitignore new file mode 100644 index 0000000000..2e3072f676 --- /dev/null +++ b/mobile/native/.gitignore @@ -0,0 +1,6 @@ +**/.build/ +.DS_Store +# Xcode user settings +xcuserdata/ + +.swiftpm/ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/AccentColor.colorset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png new file mode 100644 index 0000000000..aa98a2fa48 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000000..1542e46dd3 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Back.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 0000000000..95d75a515c --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,14 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000000..81355109eb --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Middle.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png new file mode 100644 index 0000000000..6a658321d5 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 1x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 1x.png new file mode 100644 index 0000000000..67bbd7df71 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 1x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 2x.png new file mode 100644 index 0000000000..4954719f07 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back 2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000000..eee3fd8452 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Back 1x.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Back 2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000000..de59d885ae --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,17 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Front.imagestacklayer" + }, + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000000..795cce1724 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000000..18a110e6e2 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Middle 1x.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Middle 2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 1x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 1x.png new file mode 100644 index 0000000000..78c0132707 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 1x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 2x.png new file mode 100644 index 0000000000..2913ac5e10 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle 2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 0000000000..f47ba43daa --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -0,0 +1,32 @@ +{ + "assets" : [ + { + "filename" : "App Icon - App Store.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "1280x768" + }, + { + "filename" : "App Icon.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "400x240" + }, + { + "filename" : "Top Shelf Image Wide.imageset", + "idiom" : "tv", + "role" : "top-shelf-image-wide", + "size" : "2320x720" + }, + { + "filename" : "Top Shelf Image.imageset", + "idiom" : "tv", + "role" : "top-shelf-image", + "size" : "1920x720" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 0000000000..9930dac22e --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Top Shelf Wide Image.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Top Shelf Wide Image@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image.png new file mode 100644 index 0000000000..d8f45760a4 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image@2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image@2x.png new file mode 100644 index 0000000000..0691073aa1 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide Image@2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000000..252bee2c70 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,30 @@ +{ + "images" : [ + { + "filename" : "Top Shelf Image.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Top Shelf Image@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image@2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image@2x.png new file mode 100644 index 0000000000..45c00999a1 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image@2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image.png new file mode 100644 index 0000000000..33a11005ae Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Top Shelf Image.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/Contents.json new file mode 100644 index 0000000000..3505020074 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ente-photos-icon-transparent.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/ente-photos-icon-transparent.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/ente-photos-icon-transparent.png new file mode 100644 index 0000000000..b1e6f9632d Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/EnteIcon.imageset/ente-photos-icon-transparent.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/Contents.json new file mode 100644 index 0000000000..c7ca2b51b0 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ducky.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/ducky.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/ducky.png new file mode 100644 index 0000000000..286a8315ac Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/Mascot.imageset/ducky.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/Contents.json new file mode 100644 index 0000000000..6f56042868 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ducky_camera.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ducky_camera@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ducky_camera@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera.png new file mode 100644 index 0000000000..3052a621eb Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@2x.png new file mode 100644 index 0000000000..b9d82e6b9b Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@3x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@3x.png new file mode 100644 index 0000000000..4f80b0ad9f Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_camera.imageset/ducky_camera@3x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/Contents.json b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/Contents.json new file mode 100644 index 0000000000..b5822ccfd6 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ducky_tv.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ducky_tv@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ducky_tv@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv.png new file mode 100644 index 0000000000..63199e646c Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@2x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@2x.png new file mode 100644 index 0000000000..a6b3130c53 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@2x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@3x.png b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@3x.png new file mode 100644 index 0000000000..ad34a45abe Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Assets.xcassets/ducky_tv.imageset/ducky_tv@3x.png differ diff --git a/mobile/native/ios/Apps/tv/cast/ContentView.swift b/mobile/native/ios/Apps/tv/cast/ContentView.swift new file mode 100644 index 0000000000..9de44e2909 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/ContentView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var viewModel = CastViewModel() + + var body: some View { + ZStack { + switch viewModel.currentView { + case .connecting: + StatusView( + status: .loading(viewModel.statusMessage), + onRetry: nil, + debugLogs: nil + ) + + case .pairing: + PairingView(deviceCode: viewModel.deviceCode) + + case .slideshow: + SlideshowView( + imageData: viewModel.currentImageData, + videoData: viewModel.currentVideoData, + isVideo: viewModel.currentFile?.isVideo ?? false, + slideshowService: viewModel.slideshowService + ) + + case .error: + StatusView( + status: .error(viewModel.errorMessage ?? "Unknown error"), + onRetry: viewModel.retryOperation, + debugLogs: nil + ) + + case .empty: + StatusView( + status: .empty("No photos were found in this album"), + onRetry: nil, + debugLogs: nil + ) + } + } + .animation(.easeInOut(duration: 0.6), value: viewModel.currentView) + } +} + +struct CinematicBackground: View { + var body: some View { + ZStack { + // Clean, sophisticated gradient like Spotify + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.08, blue: 0.08), + Color(red: 0.04, green: 0.04, blue: 0.04), + Color(red: 0.02, green: 0.02, blue: 0.02) + ], + startPoint: .top, + endPoint: .bottom + ) + + // Subtle brand accent - strategic placement + RadialGradient( + colors: [ + Color(red: 29/255, green: 185/255, blue: 84/255).opacity(0.08), + Color.clear + ], + center: UnitPoint(x: 0.3, y: 0.2), + startRadius: 200, + endRadius: 800 + ) + + + } + .ignoresSafeArea() + } +} + +#Preview { + ContentView() +} diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Black.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Black.ttf new file mode 100644 index 0000000000..c4ca0ca890 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Black.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BlackItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BlackItalic.ttf new file mode 100644 index 0000000000..3c5e7402a9 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BlackItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Bold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Bold.ttf new file mode 100644 index 0000000000..9cf55b7e34 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Bold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BoldItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BoldItalic.ttf new file mode 100644 index 0000000000..1fa4bb4ae2 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-BoldItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Extrabold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Extrabold.ttf new file mode 100644 index 0000000000..8a402f816c Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Extrabold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ExtraboldItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ExtraboldItalic.ttf new file mode 100644 index 0000000000..ceec8e9f2f Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ExtraboldItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Heavy.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Heavy.ttf new file mode 100644 index 0000000000..f8a5e56a2e Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Heavy.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-HeavyItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-HeavyItalic.ttf new file mode 100644 index 0000000000..af05de980b Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-HeavyItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Light.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Light.ttf new file mode 100644 index 0000000000..f85744ba07 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Light.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-LightItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-LightItalic.ttf new file mode 100644 index 0000000000..d684c7bb59 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-LightItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Medium.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Medium.ttf new file mode 100644 index 0000000000..c9ebeb55f6 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Medium.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-MediumItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-MediumItalic.ttf new file mode 100644 index 0000000000..1adf5b2dd4 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-MediumItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Regular.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Regular.ttf new file mode 100644 index 0000000000..586e79a40a Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Regular.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-RegularItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-RegularItalic.ttf new file mode 100644 index 0000000000..697cb3d2fa Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-RegularItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Semibold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Semibold.ttf new file mode 100644 index 0000000000..8268469559 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Semibold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-SemiboldItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-SemiboldItalic.ttf new file mode 100644 index 0000000000..2b8f4589f4 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-SemiboldItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Thin.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Thin.ttf new file mode 100644 index 0000000000..eecb810bb0 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-Thin.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ThinItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ThinItalic.ttf new file mode 100644 index 0000000000..09ab36fcc5 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-ThinItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLight.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLight.ttf new file mode 100644 index 0000000000..4feb0b44b5 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLight.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLightItalic.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLightItalic.ttf new file mode 100644 index 0000000000..6231150251 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Gilroy-UltraLightItalic.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Bold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Bold.ttf new file mode 100644 index 0000000000..7e1deec31e Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Bold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Light.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Light.ttf new file mode 100644 index 0000000000..ebaa005740 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Light.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Medium.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Medium.ttf new file mode 100644 index 0000000000..7e573f6498 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Medium.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Regular.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Regular.ttf new file mode 100644 index 0000000000..012d1b470d Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-Regular.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Inter-SemiBold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000000..4be54399d6 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Inter-SemiBold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Fonts/Montserrat-Bold.ttf b/mobile/native/ios/Apps/tv/cast/Fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000000..55e0b1a553 Binary files /dev/null and b/mobile/native/ios/Apps/tv/cast/Fonts/Montserrat-Bold.ttf differ diff --git a/mobile/native/ios/Apps/tv/cast/Info.plist b/mobile/native/ios/Apps/tv/cast/Info.plist new file mode 100644 index 0000000000..5702c4c328 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Info.plist @@ -0,0 +1,17 @@ + + + + + UIAppFonts + + Gilroy-Black.ttf + Gilroy-Extrabold.ttf + Inter-Regular.ttf + Inter-Medium.ttf + Inter-SemiBold.ttf + Inter-Bold.ttf + + ITSAppUsesNonExemptEncryption + + + diff --git a/mobile/native/ios/Apps/tv/cast/Models/CastModels.swift b/mobile/native/ios/Apps/tv/cast/Models/CastModels.swift new file mode 100644 index 0000000000..27c02111a3 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Models/CastModels.swift @@ -0,0 +1,166 @@ +// +// CastModels.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import Foundation +import AVKit + +// MARK: - Notification Extensions +extension Notification.Name { + static let authenticationExpired = Notification.Name("authenticationExpired") + static let slideshowRestarted = Notification.Name("slideshowRestarted") +} + +// MARK: - Slide Configuration +struct SlideConfiguration { + let imageDuration: TimeInterval + let videoDuration: TimeInterval + let useThumbnails: Bool + let shuffle: Bool + let maxImageSize: Int64 + let maxVideoSize: Int64 + let includeVideos: Bool + + // Enhanced prefetch settings + let prefetchCount: Int + let maxCacheSize: Int + let prefetchDelay: TimeInterval + let enablePrefetching: Bool + + init( + imageDuration: TimeInterval = 12.0, + videoDuration: TimeInterval = 30.0, + useThumbnails: Bool = false, + shuffle: Bool = true, + maxImageSize: Int64 = 100 * 1024 * 1024, // 100MB + maxVideoSize: Int64 = 500 * 1024 * 1024, // 500MB + includeVideos: Bool = true, + prefetchCount: Int = 3, + maxCacheSize: Int = 5, + prefetchDelay: TimeInterval = 0.5, + enablePrefetching: Bool = true + ) { + self.imageDuration = imageDuration + self.videoDuration = videoDuration + self.useThumbnails = useThumbnails + self.shuffle = shuffle + self.maxImageSize = maxImageSize + self.maxVideoSize = maxVideoSize + self.includeVideos = includeVideos + self.prefetchCount = prefetchCount + self.maxCacheSize = maxCacheSize + self.prefetchDelay = prefetchDelay + self.enablePrefetching = enablePrefetching + } + + func duration(for file: CastFile) -> TimeInterval { + return file.isVideo ? videoDuration : imageDuration + } + + static let `default` = SlideConfiguration() + + // TV-optimized configuration (use thumbnails for better performance) + static let tvOptimized = SlideConfiguration( + imageDuration: 8.0, // Faster progression for TV viewing + videoDuration: 25.0, + useThumbnails: true, // Better performance on Apple TV + shuffle: true, + maxImageSize: 50 * 1024 * 1024, // 50MB for TV + maxVideoSize: 200 * 1024 * 1024, // 200MB for TV + includeVideos: true, + prefetchCount: 3, // Prefetch next 3 slides + maxCacheSize: 5, // Keep 5 slides in cache + prefetchDelay: 0.5, // 500ms delay between prefetch operations + enablePrefetching: true // Enable prefetching for smooth transitions + ) +} + +// MARK: - Data Models +struct CastFile: Codable, Equatable { + let id: Int + let title: String + let isVideo: Bool + let isLivePhoto: Bool + let encryptedKey: String + let keyDecryptionNonce: String + let fileDecryptionHeader: String + let hash: String? // BLAKE2b hash for file content verification + + var isImage: Bool { !isVideo && !isLivePhoto } +} + +struct FileMetadata { + let fileType: Int // 0 = image, 1 = video, 2 = livePhoto + let title: String // filename with extension + let creationTime: Int64 // microseconds since epoch + let modificationTime: Int64 + let hash: String? // BLAKE2b hash for file content verification + + var isImage: Bool { fileType == 0 } + var isVideo: Bool { fileType == 1 } + var isLivePhoto: Bool { fileType == 2 } +} + +enum CastSessionState: Equatable { + case idle + case registering + case waitingForPairing(deviceCode: String) + case connected(CastPayload) + case error(String) +} + +struct CastPayload: Codable, Equatable { + let collectionID: Int + let collectionKey: String + let castToken: String +} + +struct CastDevice { + let deviceCode: String + let publicKey: Data + let privateKey: Data +} + +// MARK: - Server Response Models +struct DeviceRegistrationResponse: Codable { + let deviceCode: String +} + +struct CastDataResponse: Codable { + let encCastData: String? +} + +// MARK: - Errors +enum CastError: Error { + case networkError(String) + case serverError(Int, String?) + case decryptionError(String) + + var localizedDescription: String { + switch self { + case .networkError(let message): + return "Network error: \(message)" + case .serverError(let code, let message): + return "Server error \(code): \(message ?? "Unknown error")" + case .decryptionError(let message): + return "Decryption error: \(message)" + } + } +} + +// MARK: - Live Photo Models +struct LivePhotoComponents { + let imageData: Data? + let videoData: Data? + let imagePath: URL? + let videoPath: URL? +} + +// MARK: - Cache Metadata +struct CacheMetadata: Codable { + let fileIDs: [Int] + let totalBytes: Int +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Services/CastPairingService.swift b/mobile/native/ios/Apps/tv/cast/Services/CastPairingService.swift new file mode 100644 index 0000000000..97d99fb243 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Services/CastPairingService.swift @@ -0,0 +1,252 @@ +// +// CastPairingService.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import SwiftUI +import CryptoKit +import Foundation +import EnteCrypto + +// MARK: - Cast Session Management + +@MainActor +class CastSession: ObservableObject { + @Published var state: CastSessionState = .idle + @Published var isActive: Bool = false + + var deviceCode: String? { + if case .waitingForPairing(let code) = state { + return code + } + return nil + } + + var payload: CastPayload? { + if case .connected(let payload) = state { + return payload + } + return nil + } + + func setState(_ newState: CastSessionState) { + state = newState + isActive = !isIdle + } + + private var isIdle: Bool { + if case .idle = state { + return true + } + return false + } +} + +// MARK: - Real Cast Pairing Service + +class RealCastPairingService { + private let baseURL = "https://api.ente.io" + private var pollingTimer: Timer? + private var lastLoggedMessages: [String: Date] = [:] + private let logThrottleInterval: TimeInterval = 5.0 // 5 seconds + private var isPolling: Bool = false + private var isFetchingPayload: Bool = false + private var hasDeliveredPayload: Bool = false + private var pollingStartTime: Date? + private let initialPollingInterval: TimeInterval = 2.0 // 2 seconds initially + private let extendedPollingInterval: TimeInterval = 5.0 // 5 seconds after 60 seconds + private let pollingIntervalSwitchTime: TimeInterval = 60.0 // Switch after 60 seconds + private var hasLoggedIntervalSwitch: Bool = false + + private func getCurrentPollingInterval() -> TimeInterval { + guard let startTime = pollingStartTime else { return initialPollingInterval } + let elapsed = Date().timeIntervalSince(startTime) + let newInterval = elapsed >= pollingIntervalSwitchTime ? extendedPollingInterval : initialPollingInterval + + // Log when switching to extended interval for the first time + if elapsed >= pollingIntervalSwitchTime && newInterval == extendedPollingInterval && !hasLoggedIntervalSwitch { + print("๐Ÿ• Switched to extended polling interval (\(extendedPollingInterval)s) after \(Int(elapsed))s") + hasLoggedIntervalSwitch = true + } + + return newInterval + } + + // Generate real X25519 keypair using EnteCrypto + private func generateKeyPair() -> (publicKey: Data, privateKey: Data) { + let keys = EnteCrypto.generateCastKeyPair() + return ( + publicKey: Data(base64Encoded: keys.publicKey)!, + privateKey: Data(base64Encoded: keys.privateKey)! + ) + } + + func registerDevice() async throws -> CastDevice { + print("๐Ÿ”‘ Generating real X25519 keypair...") + let keys = generateKeyPair() + let publicKeyBase64 = keys.publicKey.base64EncodedString() + + print("๐Ÿ“ก Registering device with Ente production server...") + print("๐ŸŒ POST \(baseURL)/cast/device-info") + + let url = URL(string: "\(baseURL)/cast/device-info")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = ["publicKey": publicKeyBase64] + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CastError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8)) + } + + let deviceResponse = try JSONDecoder().decode(DeviceRegistrationResponse.self, from: data) + + print("โœ… Device registered! Code from server: \(deviceResponse.deviceCode)") + + return CastDevice( + deviceCode: deviceResponse.deviceCode, + publicKey: keys.publicKey, + privateKey: keys.privateKey + ) + } + + func startPolling(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) { + guard !hasDeliveredPayload else { return } + guard !isPolling else { return } + print("๐Ÿ” Starting real polling of Ente production server...") + pollingTimer?.invalidate() + isPolling = true + pollingStartTime = Date() + hasLoggedIntervalSwitch = false + + scheduleNextPoll(device: device, onPayloadReceived: onPayloadReceived, onError: onError) + } + + private func scheduleNextPoll(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) { + guard isPolling && !hasDeliveredPayload else { return } + + let currentInterval = getCurrentPollingInterval() + pollingTimer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: false) { [weak self] _ in + Task { + await self?.checkForPayload(device: device, onPayloadReceived: onPayloadReceived, onError: onError) + // Schedule next poll after this one completes + await MainActor.run { + self?.scheduleNextPoll(device: device, onPayloadReceived: onPayloadReceived, onError: onError) + } + } + } + } + + private func checkForPayload(device: CastDevice, onPayloadReceived: @escaping (CastPayload) -> Void, onError: @escaping (Error) -> Void) async { + if hasDeliveredPayload { return } + if isFetchingPayload { return } + isFetchingPayload = true + defer { isFetchingPayload = false } + do { + let url = URL(string: "\(baseURL)/cast/cast-data/\(device.deviceCode)")! + print("๐Ÿ“ก GET \(url.absoluteString)") + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CastError.networkError("Invalid response") + } + + if httpResponse.statusCode == 404 { + // No payload available yet - this is expected + print("โณ No encrypted payload available yet") + return + } + + guard httpResponse.statusCode == 200 else { + throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8)) + } + + let castDataResponse = try JSONDecoder().decode(CastDataResponse.self, from: data) + + guard let encryptedData = castDataResponse.encCastData else { + print("โณ No encrypted payload in response yet") + return + } + + print("๐Ÿ”“ Received encrypted payload! Decrypting...") + + // Decrypt the payload using our private key + let payload = try await decryptPayload(encryptedData: encryptedData, privateKey: device.privateKey) + + print("โœ… Successfully decrypted cast payload!") + hasDeliveredPayload = true + stopPolling() + + await MainActor.run { + onPayloadReceived(payload) + } + + } catch { + print("โŒ Polling error: \(error)") + await MainActor.run { + onError(error) + } + } + } + + private func decryptPayload(encryptedData: String, privateKey: Data) async throws -> CastPayload { + // Generate public key from private key for sealed box decryption + let publicKey: Data + do { + let curve25519PrivateKey = try CryptoKit.Curve25519.KeyAgreement.PrivateKey(rawRepresentation: privateKey) + publicKey = curve25519PrivateKey.publicKey.rawRepresentation + } catch { + throw CastError.decryptionError("Failed to derive public key from private key: \(error)") + } + + // Use EnteCrypto for cast payload decryption + do { + let decryptedData = try EnteCrypto.decryptCastPayload( + encryptedPayload: encryptedData, + recipientPublicKey: publicKey.base64EncodedString(), + recipientPrivateKey: privateKey.base64EncodedString() + ) + + // Handle potential base64 preprocessing from mobile client + let finalData: Data + if let base64String = String(data: decryptedData, encoding: .utf8), + let jsonData = Data(base64Encoded: base64String) { + finalData = jsonData + } else { + finalData = decryptedData + } + + let payload = try JSONDecoder().decode(CastPayload.self, from: finalData) + return payload + } catch { + throw CastError.decryptionError("EnteCrypto cast payload decryption failed: \(error)") + } + } + + func stopPolling() { + guard isPolling else { return } + pollingTimer?.invalidate() + pollingTimer = nil + isPolling = false + pollingStartTime = nil + print("โน๏ธ Stopped polling production server") + } + + func resetForNewSession() { + print("๐Ÿ”„ Resetting pairing service for new session") + stopPolling() + hasDeliveredPayload = false + hasLoggedIntervalSwitch = false + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Services/FileCache.swift b/mobile/native/ios/Apps/tv/cast/Services/FileCache.swift new file mode 100644 index 0000000000..38535a4245 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Services/FileCache.swift @@ -0,0 +1,189 @@ +// +// FileCache.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import Foundation + +// MARK: - Persistent Thread-Safe File Cache + +actor ThreadSafeFileCache { + private var cache: [Int: Data] = [:] + private var cacheOrder: [Int] = [] + private var totalBytes: Int = 0 + private let maxBytes: Int + private let shrinkTargetBytes: Int + private let cacheDirectory: URL + private let metadataURL: URL + + init(maxBytes: Int, shrinkTargetBytes: Int) { + self.maxBytes = maxBytes + self.shrinkTargetBytes = shrinkTargetBytes + + // Create persistent cache directory + let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + self.cacheDirectory = documentsPath.appendingPathComponent("EnteFileCache") + self.metadataURL = cacheDirectory.appendingPathComponent("cache_metadata.json") + + // Create cache directory if it doesn't exist + try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + + // Load existing cache from disk + loadCacheFromDisk() + } + + func get(_ fileID: Int) -> Data? { + // Check memory cache first + if let data = cache[fileID] { + return data + } + + // Check disk cache + let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache") + if let data = try? Data(contentsOf: fileURL) { + // Load into memory cache for faster future access + cache[fileID] = data + if !cacheOrder.contains(fileID) { + cacheOrder.append(fileID) + } + totalBytes += data.count + return data + } + + return nil + } + + func set(_ fileID: Int, data: Data) { + // Remove existing data if present + if let existingData = cache[fileID] { + totalBytes -= existingData.count + cacheOrder.removeAll { $0 == fileID } + } + + // Add new data to memory cache + cache[fileID] = data + cacheOrder.append(fileID) + totalBytes += data.count + + // Save to disk cache + let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache") + try? data.write(to: fileURL) + + // Enforce limits + enforceLimits() + + // Save metadata + saveCacheMetadata() + + print("๐Ÿ’พ Cached file \(fileID) content (\(data.count) bytes) - Cache size: \(cache.count) files") + } + + func remove(_ fileID: Int) { + if let removedData = cache.removeValue(forKey: fileID) { + totalBytes -= removedData.count + cacheOrder.removeAll { $0 == fileID } + + // Remove from disk cache + let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache") + try? FileManager.default.removeItem(at: fileURL) + + // Save updated metadata + saveCacheMetadata() + + print("๐Ÿ—‘๏ธ Removed cached content for file \(fileID) (\(removedData.count) bytes)") + } + } + + func clear() { + let clearedCount = cache.count + cache.removeAll() + cacheOrder.removeAll() + totalBytes = 0 + + // Clear disk cache + try? FileManager.default.removeItem(at: cacheDirectory) + try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + + // Clear metadata + try? FileManager.default.removeItem(at: metadataURL) + + print("๐Ÿ—‘๏ธ Cleared file content cache (\(clearedCount) files)") + } + + func getStats() -> (count: Int, totalSize: Int) { + return (count: cache.count, totalSize: totalBytes) + } + + func getCachedFileIDs() -> [Int] { + return Array(cache.keys) + cacheOrder.filter { !cache.keys.contains($0) } + } + + private func enforceLimits() { + guard totalBytes > maxBytes else { return } + + var removedBytes = 0 + while totalBytes - removedBytes > shrinkTargetBytes, let oldest = cacheOrder.first { + cacheOrder.removeFirst() + if let data = cache.removeValue(forKey: oldest) { + removedBytes += data.count + + // Remove from disk cache + let fileURL = cacheDirectory.appendingPathComponent("\(oldest).cache") + try? FileManager.default.removeItem(at: fileURL) + + print("๐Ÿงน Evicted file \(oldest) (\(data.count) bytes) to control cache size") + } + } + totalBytes -= removedBytes + + // Save updated metadata after eviction + saveCacheMetadata() + + print("๐Ÿ“ฆ Cache GC complete: now \(cache.count) files, \(totalBytes) bytes") + } + + private func loadCacheFromDisk() { + // Load metadata + guard let metadataData = try? Data(contentsOf: metadataURL), + let metadata = try? JSONDecoder().decode(CacheMetadata.self, from: metadataData) else { + print("๐Ÿ“‚ No existing cache metadata found - starting fresh") + return + } + + print("๐Ÿ“‚ Loading existing cache from disk - \(metadata.fileIDs.count) files") + + // Load cache order and calculate total bytes + var loadedBytes = 0 + var validFileIDs: [Int] = [] + + for fileID in metadata.fileIDs { + let fileURL = cacheDirectory.appendingPathComponent("\(fileID).cache") + if FileManager.default.fileExists(atPath: fileURL.path) { + if let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let fileSize = attributes[.size] as? Int { + loadedBytes += fileSize + validFileIDs.append(fileID) + } + } + } + + cacheOrder = validFileIDs + totalBytes = loadedBytes + + print("๐Ÿ“‚ Loaded \(validFileIDs.count) cached files (\(loadedBytes) bytes) from disk") + + // Clean up any invalid entries + if validFileIDs.count != metadata.fileIDs.count { + saveCacheMetadata() + } + } + + private func saveCacheMetadata() { + let metadata = CacheMetadata(fileIDs: cacheOrder, totalBytes: totalBytes) + if let data = try? JSONEncoder().encode(metadata) { + try? data.write(to: metadataURL) + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Utils/FontUtils.swift b/mobile/native/ios/Apps/tv/cast/Utils/FontUtils.swift new file mode 100644 index 0000000000..f772d0a297 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Utils/FontUtils.swift @@ -0,0 +1,54 @@ +// +// FontUtils.swift +// tv +// +// Created on 28/08/25. +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +struct FontUtils { + + // MARK: - Safe Font Creation + + private static func safeFont(name: String, size: CGFloat, fallback: Font) -> Font { + #if canImport(UIKit) + if UIFont(name: name, size: size) != nil { + return .custom(name, size: size) + } else { + return fallback + } + #else + return .custom(name, size: size) + #endif + } + + // MARK: - Font Presets + + static func gilroyBlack(size: CGFloat) -> Font { + return safeFont(name: "Gilroy-Black", size: size, fallback: .system(size: size, weight: .black)) + } + + static func gilroyExtraBold(size: CGFloat) -> Font { + return safeFont(name: "Gilroy-Extrabold", size: size, fallback: .system(size: size, weight: .heavy)) + } + + static func interRegular(size: CGFloat) -> Font { + return safeFont(name: "Inter-Regular", size: size, fallback: .system(size: size, weight: .regular)) + } + + static func interMedium(size: CGFloat) -> Font { + return safeFont(name: "Inter-Medium", size: size, fallback: .system(size: size, weight: .medium)) + } + + static func interSemiBold(size: CGFloat) -> Font { + return safeFont(name: "Inter-SemiBold", size: size, fallback: .system(size: size, weight: .semibold)) + } + + static func interBold(size: CGFloat) -> Font { + return safeFont(name: "Inter-Bold", size: size, fallback: .system(size: size, weight: .bold)) + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/Utils/ScreenSaverManager.swift b/mobile/native/ios/Apps/tv/cast/Utils/ScreenSaverManager.swift new file mode 100644 index 0000000000..7de3c52a4e --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Utils/ScreenSaverManager.swift @@ -0,0 +1,68 @@ +// +// ScreenSaverManager.swift +// tv +// +// Created by Claude on 03/09/25. +// + +import Foundation +import UIKit + +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +class ScreenSaverManager: ObservableObject { + private static let shared = ScreenSaverManager() + private var isDisabled = false + private var refreshTimer: Timer? + + static func preventScreenSaver() { + shared.startPrevention() + } + + static func allowScreenSaver() { + shared.stopPrevention() + } + + private func startPrevention() { + guard !isDisabled else { + print("๐Ÿšซ Screen saver prevention already enabled") + return + } + + #if os(tvOS) + UIApplication.shared.isIdleTimerDisabled = true + isDisabled = true + print("๐Ÿšซ Screen saver prevention enabled") + + // Fallback for problematic tvOS versions where isIdleTimerDisabled doesn't work reliably + // This timer periodically refreshes the setting to ensure it stays disabled + refreshTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in + UIApplication.shared.isIdleTimerDisabled = false + UIApplication.shared.isIdleTimerDisabled = true + } + #endif + } + + private func stopPrevention() { + guard isDisabled else { + print("โœ… Screen saver prevention already disabled") + return + } + + #if os(tvOS) + refreshTimer?.invalidate() + refreshTimer = nil + UIApplication.shared.isIdleTimerDisabled = false + isDisabled = false + print("โœ… Screen saver prevention disabled") + #endif + } + + // Cleanup method for app termination + static func cleanup() { + shared.stopPrevention() + } +} \ No newline at end of file diff --git a/mobile/native/ios/Apps/tv/cast/ViewModels/CastViewModel.swift b/mobile/native/ios/Apps/tv/cast/ViewModels/CastViewModel.swift new file mode 100644 index 0000000000..530ba3a99e --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/ViewModels/CastViewModel.swift @@ -0,0 +1,398 @@ +// +// CastViewModel.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import SwiftUI +import Combine + +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +class CastViewModel: ObservableObject { + // MARK: - Published Properties + @Published var currentView: CurrentView = .connecting + @Published var deviceCode: String = "" + @Published var currentImageData: Data? + @Published var currentVideoData: Data? + @Published var currentFile: CastFile? + @Published var statusMessage: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + // MARK: - Private Properties + private var cancellables = Set() + private let pairingService: RealCastPairingService + private var lastLoggedMessages: [String: Date] = [:] + private let logThrottleInterval: TimeInterval = 3.0 // 3 seconds + private let castSession: CastSession + + // MARK: - Public Properties + public let slideshowService: RealSlideshowService + + enum CurrentView { + case pairing + case connecting + case slideshow + case error + case empty + } + + init() { + print("๐Ÿš€ Initializing with REAL Ente production server calls!") + + self.castSession = CastSession() + self.pairingService = RealCastPairingService() + self.slideshowService = RealSlideshowService() + + setupBindings() + + // Auto-start cast session on app launch (like web implementation) + startCastSession() + } + + private func setupBindings() { + // Bind to cast session state changes + castSession.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.handleStateChange(state) + } + .store(in: &cancellables) + + // Bind to slideshow service updates + slideshowService.$currentImageData + .receive(on: DispatchQueue.main) + .assign(to: \.currentImageData, on: self) + .store(in: &cancellables) + // Auto-transition once first image arrives (covers single-image albums and empty-to-populated) + slideshowService.$currentImageData + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + guard let self = self else { return } + if data != nil && (self.currentView == .connecting || self.currentView == .empty) { + self.currentView = .slideshow + self.statusMessage = "" + self.isLoading = false + self.errorMessage = nil + } + } + .store(in: &cancellables) + + slideshowService.$currentVideoData + .receive(on: DispatchQueue.main) + .assign(to: \.currentVideoData, on: self) + .store(in: &cancellables) + slideshowService.$currentVideoData + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + guard let self = self else { return } + if data != nil && (self.currentView == .connecting || self.currentView == .empty) { + self.currentView = .slideshow + self.statusMessage = "" + self.isLoading = false + self.errorMessage = nil + } + } + .store(in: &cancellables) + + slideshowService.$currentFile + .receive(on: DispatchQueue.main) + .assign(to: \.currentFile, on: self) + .store(in: &cancellables) + + slideshowService.$error + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] error in + self?.handleSlideshowError(error) + } + .store(in: &cancellables) + + // Listen for authentication expired notifications + NotificationCenter.default.publisher(for: .authenticationExpired) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleAuthenticationExpired() + } + .store(in: &cancellables) + + // Listen for slideshow restarted notifications + NotificationCenter.default.publisher(for: .slideshowRestarted) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleSlideshowRestarted() + } + .store(in: &cancellables) + + // Listen for app lifecycle events to manage screen saver prevention + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) + .receive(on: DispatchQueue.main) + .sink { _ in + ScreenSaverManager.allowScreenSaver() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + if self?.currentView == .slideshow { + ScreenSaverManager.preventScreenSaver() + } + } + .store(in: &cancellables) + } + + // MARK: - Public Methods + + func startCastSession() { + castSession.setState(.registering) + currentView = .connecting + isLoading = true + statusMessage = "Registering device..." + + Task { + do { + // Register device with the server + let device = try await pairingService.registerDevice() + + await MainActor.run { + deviceCode = device.deviceCode + castSession.setState(.waitingForPairing(deviceCode: device.deviceCode)) + currentView = .pairing + statusMessage = "Waiting for connection..." + isLoading = false + } + + // Start polling for payload + pairingService.startPolling( + device: device, + onPayloadReceived: { [weak self] payload in + Task { @MainActor in + self?.handlePayloadReceived(payload) + } + }, + onError: { [weak self] error in + Task { @MainActor in + self?.handleNetworkError(error) + } + } + ) + + } catch { + handleNetworkError(error) + } + } + } + + func resetSession() async { + print("๐Ÿ”„ Resetting cast session...") + + // Ensure screen saver prevention is disabled during reset + ScreenSaverManager.allowScreenSaver() + + currentView = .connecting + deviceCode = "" + currentImageData = nil + currentVideoData = nil + currentFile = nil + statusMessage = "" + isLoading = false + errorMessage = nil + + // Clean up services + pairingService.stopPolling() + await slideshowService.stop() + castSession.setState(.idle) + print("โœ… Cast session reset complete") + } + + private func handlePayloadReceived(_ payload: CastPayload) { + // Idempotency: if we're already connected with same payload, skip + if case .connected(let existing) = castSession.state, existing == payload { + return + } + print("๐ŸŽ‰ Cast payload received successfully!") + + // Update cast session with the payload + castSession.setState(.connected(payload)) + currentView = .connecting + statusMessage = "" + isLoading = true + + // Stop polling once we receive payload + // pairingService.stopPolling() // redundant; pairing service already stops itself when delivering payload + + // Start the slideshow with the full payload + Task { + // Add a small delay to prevent flickering from rapid state changes + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + await slideshowService.start(castPayload: payload) + + // Additional delay to ensure slideshow is properly loaded + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + + await MainActor.run { + let hasError = slideshowService.error != nil && !slideshowService.error!.isEmpty + + if hasError { + handleSlideshowError(slideshowService.error!) + } else { + currentView = .slideshow + statusMessage = "" + isLoading = false + } + } + } + } + + func retryOperation() { + errorMessage = nil + + switch currentView { + case .error: + startCastSession() + default: + break + } + } + + // MARK: - Slideshow Navigation + + func nextSlide() { + guard currentView == .slideshow else { return } + + Task { + await slideshowService.nextSlide() + } + } + + func previousSlide() { + guard currentView == .slideshow else { return } + + Task { + await slideshowService.previousSlide() + } + } + + // MARK: - Private Methods + + private func handleStateChange(_ state: CastSessionState) { + switch state { + case .idle: + currentView = .connecting + + case .registering: + currentView = .connecting + statusMessage = "Registering device..." + isLoading = true + + case .waitingForPairing(let code): + deviceCode = code + currentView = .pairing + statusMessage = "Waiting for connection..." + isLoading = false + + case .connected(let payload): + handlePayloadReceived(payload) + + case .error(let message): + handleError(message) + } + } + + + private func handleSlideshowError(_ error: String) { + // Prevent stale slideshow errors from a previous (expired) session + // from overriding the fresh pairing code UI. Only react to slideshow + // errors once we are actually in a connected/slideshow flow. + // Exception: Allow empty state errors even during connecting + let isEmptyStateError = error.contains("No media files") || + error.contains("available in this album") || + error.contains("available in this collection") || + error.contains("Empty file list") + + if (currentView == .pairing || currentView == .connecting) && !isEmptyStateError { + // Ignore slideshow-originating errors during pairing / reconnection, except empty state + return + } + + if isEmptyStateError { + currentView = .empty + statusMessage = "" + isLoading = false + } else { + handleError(error) + } + } + + + private func handleError(_ message: String) { + print("โŒ Cast Error: \(message)") + currentView = .error + errorMessage = message + isLoading = false + statusMessage = "" + castSession.setState(.error(message)) + } + + private func handleNetworkError(_ error: Error) { + // Show error immediately + handleError("An error occurred: \(error.localizedDescription)") + + // Wait 5 seconds then reset state for new device registration + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + await MainActor.run { + // Reset to start new device registration + startCastSession() + } + } + } + + private func handleAuthenticationExpired() { + print("๐Ÿ” Authentication expired notification received - clearing all state and restarting") + Task { + await resetSession() + // Clear all in-memory token state in slideshow service + await slideshowService.clearExpiredTokenState() + // Reset pairing service to allow fresh polling + pairingService.resetForNewSession() + + // Ensure we're in a clean state before starting new session + await MainActor.run { + // Directly set to pairing once device registration begins to avoid brief empty/error flicker + currentView = .connecting + errorMessage = nil + statusMessage = "Starting fresh session..." + isLoading = true + } + + // Generate fresh device code for new pairing + startCastSession() + } + } + + private func handleSlideshowRestarted() { + print("๐ŸŽฌ Slideshow restarted notification received") + // Wait for image data to be available before transitioning + // The binding will handle the transition when data arrives + statusMessage = "" + isLoading = false + errorMessage = nil + + // Only transition if we already have image data + if slideshowService.currentImageData != nil || slideshowService.currentVideoData != nil { + print("๐ŸŽฌ Image/video data available - transitioning to slideshow view") + currentView = .slideshow + } else { + print("โณ Waiting for image data before transitioning to slideshow view") + } + } + +} diff --git a/mobile/native/ios/Apps/tv/cast/ViewModels/SlideshowService.swift b/mobile/native/ios/Apps/tv/cast/ViewModels/SlideshowService.swift new file mode 100644 index 0000000000..5021de2e98 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/ViewModels/SlideshowService.swift @@ -0,0 +1,1412 @@ +// +// SlideshowService.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import SwiftUI +import AVKit +import Foundation +import EnteCrypto +import ZIPFoundation + +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +class RealSlideshowService: ObservableObject { + // MARK: - Published Properties + @Published var currentImageData: Data? + @Published var currentVideoData: Data? // Deprecated path (kept for compatibility) + @Published var currentVideoURL: URL? // Temp file URL for AVPlayer playback + @Published var livePhotoVideoData: Data? // Video component for live photos + @Published var videoPlayer: AVPlayer? + @Published var isVideoPlaying: Bool = false + @Published var videoCurrentTime: Double = 0 + @Published var videoDuration: Double = 0 + @Published var currentFile: CastFile? + @Published var error: String? + + // Enhanced state management + @Published var isPlaying: Bool = false + @Published var isPaused: Bool = false + @Published var slideLoadingProgress: Double = 0.0 + @Published var currentSlideIndex: Int = 0 + @Published var totalSlides: Int = 0 + + // MARK: - Private Properties + + // Global file list management + private var allFiles: [CastFile] = [] + private var currentFileIndex: Int = 0 + private var lastUpdateTime: Int64 = 0 + private var isLoadingMore: Bool = false + private var hasCompletedInitialFetch: Bool = false + private var storedCastPayload: CastPayload? + + // Periodic diff polling + private var diffPollingTimer: Timer? + private let diffPollingInterval: TimeInterval = 5.0 // 5 seconds + private var isPeriodicPollingEnabled: Bool = true + private var currentFileWasDeleted: Bool = false + private var isStopping: Bool = false // Flag to indicate service is being stopped + + // Enhanced slideshow features + private var slideTimer: Timer? + private var prefetchCache: [Int: Data] = [:] + private var videoTempFiles: [Int: URL] = [:] + private let slideshowConfiguration = SlideConfiguration.tvOptimized + private var slideTimeRemaining: TimeInterval = 0 + private var slidePauseTime: Date? + private var slideStartTime: Date? + + // File content caching - prevent redundant downloads + private let fileCache = ThreadSafeFileCache( + maxBytes: 4096 * 1024 * 1024, // 4GB + shrinkTargetBytes: 2048 * 1024 * 1024 // 2GB + ) + + // Simplified mode: only load first file (no slideshow navigation) + private var didDisplayFirstFile: Bool = false + + private let baseURL = "https://api.ente.io" + private let castDownloadURL = "https://cast-albums.ente.io/download" + + // Configuration Flags + private let verboseFileLogging = false // Reduces per-file spam unless true + private let verboseDecryptionLogging = false // Detailed size/key logs + private let enablePreviewFallback = true // Fetch preview image if full decrypt fails + + // 401 Error Handling + private var isHandlingAuthExpiry: Bool = false + + // MARK: - Slideshow Controls + + func pause() { + slideTimer?.invalidate() + slideTimer = nil + isPlaying = false + isPaused = true + + // Calculate remaining time when pausing + if let startTime = slideStartTime { + let elapsed = Date().timeIntervalSince(startTime) + let totalDuration = currentFile.map { slideshowConfiguration.duration(for: $0) } ?? 0 + slideTimeRemaining = max(0, totalDuration - elapsed) + } + slidePauseTime = Date() + } + + func resume() { + guard !allFiles.isEmpty else { return } + isPaused = false + isPlaying = true + slidePauseTime = nil + + // Resume with remaining time if we have it, otherwise start fresh + if slideTimeRemaining > 0 { + startSlideTimer(withDuration: slideTimeRemaining) + } else { + startSlideTimer() + } + } + + func togglePlayPause() { + if isPaused || !isPlaying { + resume() + } else { + pause() + } + } + + func nextSlide() async { + // If we no longer have an active payload (e.g. after auth expiry reset) just ignore any stray timer fires + guard storedCastPayload != nil else { return } + guard !allFiles.isEmpty else { + // Only surface empty-state error if we're in an active slideshow session (payload present) + await MainActor.run { + self.error = "No media files available in this album" + } + return + } + + // For single photo, just restart the timer without changing index + if allFiles.count == 1 { + print("๐Ÿ“ธ Single photo in album - restarting timer") + if isPlaying && !isPaused { + startSlideTimer() + } + return + } + + // Check if current file was deleted and handle accordingly + if currentFileWasDeleted { + print("โญ๏ธ Handling deleted current file") + currentFileWasDeleted = false + // Current index already adjusted in processDiffBatch + if currentFileIndex >= allFiles.count { + currentFileIndex = 0 + } + } else { + // Normal progression to next slide + currentFileIndex = (currentFileIndex + 1) % allFiles.count + } + + await displaySlideAtCurrentIndex() + if isPlaying && !isPaused { + startSlideTimer() + } + } + + func previousSlide() async { + guard !allFiles.isEmpty else { return } + currentFileIndex = currentFileIndex > 0 ? currentFileIndex - 1 : allFiles.count - 1 + await displaySlideAtCurrentIndex() + if isPlaying && !isPaused { + startSlideTimer() + } + } + + // MARK: - Main Entry Points + + func start(castPayload: CastPayload) async { + print("๐ŸŽฌ Starting slideshow with payload:") + + // Enable screen saver prevention for slideshow + ScreenSaverManager.preventScreenSaver() + + // Clear any expired state first, then store new payload + await clearExpiredTokenState() + storedCastPayload = castPayload + await MainActor.run { + // Don't blindly nuke existing UI state until we actually know file list status + // Just mark as loading; CastViewModel controls high-level view transitions. + if self.error != nil { self.error = nil } + } + + do { + print("๐Ÿ“ก Fetching files from Ente museum server...") + + // Initialize file list and fetch all files with pagination + await initializeFileList(castPayload: castPayload) + + let fileCount = await MainActor.run { allFiles.count } + if fileCount == 0 { + await MainActor.run { + self.error = "No media files available in this album" + } + return + } + + print("๐Ÿ“ Found \(fileCount) files total") + + // Clean up cache for files no longer in the collection + let validFileIDs = Set(await MainActor.run { allFiles.map { $0.id } }) + await cleanupExpiredCache(validFileIDs: validFileIDs) + + // Initialize slideshow state + await MainActor.run { + self.totalSlides = fileCount + self.currentFileIndex = 0 + self.currentSlideIndex = 0 + self.isPlaying = true + self.isPaused = false + } + + // Display first file and start slideshow + await displaySlideAtCurrentIndex() + + // For single photo, ensure timer is started even if it's an image + if fileCount == 1 { + print("๐Ÿ“ธ Starting slideshow with single photo") + startSlideTimer() + } else { + startSlideTimer() + } + print("โœ… Enhanced slideshow started with \(fileCount) slide(s)") + + } catch { + print("โŒ Failed to start slideshow: \(error)") + await MainActor.run { + self.error = "Failed to load slideshow: \(error.localizedDescription)" + } + } + } + + func stop() async { + print("โน๏ธ Stopping real slideshow service...") + + // Disable screen saver prevention when stopping slideshow + ScreenSaverManager.allowScreenSaver() + + // Set stopping flag to cancel ongoing operations + await MainActor.run { + isStopping = true + stopPeriodicDiffPolling() + } + + // Invalidate slide timer to avoid post-reset nextSlide() firing that could trigger false empty-state UI + slideTimer?.invalidate() + slideTimer = nil + + // Clear navigation state + await MainActor.run { + currentFile = nil + currentImageData = nil + currentVideoData = nil + currentVideoURL = nil + livePhotoVideoData = nil + error = nil + } + + // Clear cache to free memory only if explicitly needed + // await clearCache() // Commented out to preserve cache across sessions + + // Print final cache statistics + let stats = await getCacheStats() + print("๐Ÿ“Š Final cache stats: \(stats.count) files, \(stats.totalSize) bytes") + print("โœ… Real slideshow service stopped") + + // Reset stopping flag for next session + await MainActor.run { + isStopping = false + } + } + + func clearExpiredTokenState() async { + print("๐Ÿงน Clearing expired token state from slideshow service") + + // Ensure screen saver prevention is disabled when clearing expired state + ScreenSaverManager.allowScreenSaver() + + await MainActor.run { + // Ensure any existing slide timer from previous session is cancelled to prevent spurious empty-state errors + slideTimer?.invalidate() + slideTimer = nil + storedCastPayload = nil + lastUpdateTime = 0 + hasCompletedInitialFetch = false + allFiles.removeAll() + prefetchCache.removeAll() + // Clear any error state that might trigger empty view + error = nil + currentFile = nil + currentImageData = nil + currentVideoData = nil + currentVideoURL = nil + livePhotoVideoData = nil + } + // Don't clear cache automatically - preserve across sessions + // await clearCache() // Only clear if needed for debugging + } + + // MARK: - File List Management + + @MainActor + private func initializeFileList(castPayload: CastPayload) async { + // Check if we've already completed the initial fetch + if hasCompletedInitialFetch && !allFiles.isEmpty { + print("๐Ÿ“‹ Using cached file list with \(allFiles.count) files") + return + } + + // Reset state for fresh fetch + print("๐Ÿ“ก Performing initial diff fetch...") + allFiles.removeAll() + lastUpdateTime = 0 + currentFileIndex = 0 + hasCompletedInitialFetch = false + + // Fetch all pages until hasMore is false + do { + try await fetchAllFiles(castPayload: castPayload) + // Mark as completed only after successfully fetching all pages + hasCompletedInitialFetch = true + // Shuffle order to randomize slideshow similar to web experience + if !allFiles.isEmpty { + allFiles.shuffle() + print("๐Ÿ”€ Shuffled file order for slideshow") + } + print("โœ… Initial diff fetch completed - \(allFiles.count) files cached (shuffled)") + } catch { + print("โŒ Failed to fetch files: \(error)") + hasCompletedInitialFetch = false // Ensure we retry on next attempt + self.error = "Failed to load files: \(error.localizedDescription)" + } + } + + private func fetchAllFiles(castPayload: CastPayload) async throws { + var hasMore = true + var sinceTime = await MainActor.run { lastUpdateTime } + + while hasMore { + if verboseFileLogging { print("๐Ÿ“ก Fetching files since time: \(sinceTime)") } + let result = try await fetchFilesBatch(castPayload: castPayload, sinceTime: sinceTime) + + // Process the batch on MainActor + await processDiffBatch(result.files, collectionKey: castPayload.collectionKey) + + // Update pagination state on MainActor + await MainActor.run { + if result.latestUpdateTime > self.lastUpdateTime { + print("๐Ÿ“… Initial fetch updating lastUpdateTime: \(self.lastUpdateTime) โ†’ \(result.latestUpdateTime)") + self.lastUpdateTime = result.latestUpdateTime + } + } + + hasMore = result.hasMore + sinceTime = result.latestUpdateTime + + } + + print("๐Ÿ Initial diff fetch complete - total files cached: \(await MainActor.run { allFiles.count })") + + // Start periodic polling after initial fetch completes + await MainActor.run { + startPeriodicDiffPolling() + } + } + + private func fetchFilesBatch(castPayload: CastPayload, sinceTime: Int64) async throws -> (files: [[String: Any]], hasMore: Bool, latestUpdateTime: Int64) { + // Use the collection ID and sinceTime in the API call + let url = URL(string: "\(baseURL)/cast/diff?collectionID=\(castPayload.collectionID)&sinceTime=\(sinceTime)")! + + + var request = URLRequest(url: url) + request.setValue(castPayload.castToken, forHTTPHeaderField: "X-Cast-Access-Token") + // Include collection key in headers for server-side decryption if needed + request.setValue(castPayload.collectionKey, forHTTPHeaderField: "X-Collection-Key") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CastError.networkError("Invalid response") + } + + + if let responseString = String(data: data, encoding: .utf8) { + + } + + guard httpResponse.statusCode == 200 else { + // Handle 401 specially - this means token expired, need to reset to pairing + if httpResponse.statusCode == 401 { + await handleUnauthorizedError() + throw CastError.serverError(401, "Authentication expired - resetting to pairing mode") + } + throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8)) + } + + // Parse the diff response + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let diff = json["diff"] as? [[String: Any]] else { + print("โŒ Failed to parse JSON response") + throw CastError.networkError("Invalid JSON response") + } + + let hasMore = json["hasMore"] as? Bool ?? false + + // Find the latest update time from this batch + var latestUpdateTime = sinceTime + var foundAnyUpdates = false + for item in diff { + if let updateTime = item["updationTime"] as? Int64 { + latestUpdateTime = max(latestUpdateTime, updateTime) + foundAnyUpdates = true + } + } + + // If no items had updationTime, keep the original sinceTime + if !foundAnyUpdates { + latestUpdateTime = sinceTime + } + + + + return (files: diff, hasMore: hasMore, latestUpdateTime: latestUpdateTime) + } + + @MainActor + private func processDiffBatch(_ items: [[String: Any]], collectionKey: String) async { + let wasEmpty = allFiles.isEmpty + var currentFileChanged = false + let originalCurrentFile = currentFileIndex < allFiles.count ? allFiles[currentFileIndex] : nil + + for item in items { + guard let id = item["id"] as? Int else { + print(" โŒ No ID found in item") + continue + } + + let isDeleted = item["isDeleted"] as? Bool ?? false + + if isDeleted { + // Remove file from list if it exists + if let index = allFiles.firstIndex(where: { $0.id == id }) { + let removedFile = allFiles.remove(at: index) + print(" ๐Ÿ—‘๏ธ Removed deleted file: \(removedFile.title) (ID: \(id))") + + // Check if deleted file was the currently playing one + if originalCurrentFile?.id == id { + currentFileWasDeleted = true + print(" โš ๏ธ Currently playing file was deleted - will handle gracefully") + + // Immediately move to next slide if current was deleted + Task { + await self.nextSlide() + } + } + + // Adjust current index if necessary + if index < currentFileIndex && currentFileIndex > 0 { + currentFileIndex -= 1 + } else if index == currentFileIndex && currentFileIndex >= allFiles.count && !allFiles.isEmpty { + currentFileIndex = 0 // Wrap around to beginning + } + + // Remove from caches + prefetchCache.removeValue(forKey: id) + await removeCachedFileContent(fileID: id) + + } else { + print(" โญ๏ธ Skipping deleted file \(id) (not in list)") + } + } else { + // Add or update file + do { + // Only decrypt metadata here, not file content + if let file = try await decryptFileMetadata(item: item, collectionKey: collectionKey) { + // Skip pure videos (we only want images + live photos) + if file.isVideo && !file.isLivePhoto { + if verboseFileLogging { print(" ๐Ÿšซ Skipping video file: \(file.title) (ID: \(id))") } + continue + } + // Check if file already exists + if let existingIndex = allFiles.firstIndex(where: { $0.id == id }) { + let oldFile = allFiles[existingIndex] + allFiles[existingIndex] = file + print(" ๐Ÿ”„ Updated file: \(file.title) (ID: \(id))") + + // Check if hash changed - clear cache if so + if oldFile.hash != file.hash && file.hash != nil { + print(" ๐Ÿงน Hash changed for file \(id) - clearing cache") + prefetchCache.removeValue(forKey: id) + await removeCachedFileContent(fileID: id) + } + + // Track if current file was modified + if existingIndex == currentFileIndex { + currentFileChanged = true + } + } else { + allFiles.append(file) + print(" โœ… Added file: \(file.title) (ID: \(id))") + } + } + } catch { + print(" โŒ Error processing file \(id): \(error)") + } + } + } + + print("๐Ÿ“‹ File list now contains \(allFiles.count) files") + + // Handle state transitions + if allFiles.isEmpty { + // Transition to empty state + print("๐Ÿ“ญ All files removed - showing empty state") + slideTimer?.invalidate() + slideTimer = nil + isPlaying = false + isPaused = false + currentImageData = nil + currentVideoData = nil + currentVideoURL = nil + livePhotoVideoData = nil + totalSlides = 0 + currentSlideIndex = 0 + currentFileIndex = 0 + error = "No media files available in this album" + } else if wasEmpty { + // Transition from empty to having files + print("๐Ÿ“ท Files added to empty album - starting slideshow") + currentFileIndex = 0 + currentSlideIndex = 0 + totalSlides = allFiles.count + + // Restart slideshow if we have a stored payload + if let payload = storedCastPayload { + print("๐Ÿ”„ Restarting slideshow with \(allFiles.count) files") + error = nil // Clear error first to avoid UI flicker + + // Display first slide and start slideshow + Task { + await displaySlideAtCurrentIndex() + await MainActor.run { + self.isPlaying = true + self.isPaused = false + NotificationCenter.default.post(name: .slideshowRestarted, object: nil) + } + startSlideTimer() + } + } + } else { + // Normal update - just update count + totalSlides = allFiles.count + + // Ensure current index is valid + if currentFileIndex >= allFiles.count { + currentFileIndex = 0 + } + + // If current file was modified, reload it + if currentFileChanged && !allFiles.isEmpty { + Task { + await displaySlideAtCurrentIndex() + } + } + } + + // Clean up expired cache entries after processing batch + let currentValidFileIDs = Set(allFiles.map { $0.id }) + Task { + await cleanupExpiredCache(validFileIDs: currentValidFileIDs) + } + } + + // MARK: - Slide Display & Navigation + + private func displaySlideAtCurrentIndex() async { + guard currentFileIndex >= 0, currentFileIndex < allFiles.count, + let payload = storedCastPayload else { return } + + await MainActor.run { + currentSlideIndex = currentFileIndex + slideLoadingProgress = 0.0 + } + + // Check cache first + if let cachedData = prefetchCache[currentFileIndex] { + await updateCurrentSlide(with: cachedData, file: allFiles[currentFileIndex]) + await MainActor.run { slideLoadingProgress = 1.0 } + return + } + + // Load from network + do { + let file = allFiles[currentFileIndex] + print("๐Ÿ”„ Loading file \(file.id): \(file.title) at index \(currentFileIndex)") + await MainActor.run { slideLoadingProgress = 0.5 } + + let decryptedData = try await downloadAndDecryptFileContent( + castPayload: payload, + file: file + ) + + print("โœ… Successfully loaded file \(file.id): \(file.title) (\(decryptedData.count) bytes)") + + // Cache the data + prefetchCache[currentFileIndex] = decryptedData + + await updateCurrentSlide(with: decryptedData, file: file) + await MainActor.run { slideLoadingProgress = 1.0 } + + // Start prefetching next few slides + startPrefetching() + + } catch { + let file = allFiles[currentFileIndex] + print("โŒ Failed to load file \(file.id): \(file.title) at index \(currentFileIndex) - \(error)") + + // Auto-skip problematic files and continue slideshow + await MainActor.run { + slideLoadingProgress = 0.0 + } + + // Try next slide automatically after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + Task { + await self.skipToNextSlide() + } + } + } + } + + @MainActor + private func updateCurrentSlide(with data: Data, file: CastFile) { + // Seamless transition: don't nuke existing image until replacement assigned + let wasEmpty = error == "No media files available in this album" + error = nil + currentFile = file + + if file.isLivePhoto { + // Handle live photo: extract image component only, store video separately + do { + let components = try extractLivePhotoComponents(from: data) + + // For live photos, only set the image component for display + if let imageData = components.imageData { + currentImageData = imageData + print("๐Ÿ“ธ Live photo image component loaded: \(imageData.count) bytes") + } else { + print("โš ๏ธ Live photo missing image component - using original data as fallback") + currentImageData = data + } + + // Store video component separately for long-press playback + if let videoData = components.videoData { + livePhotoVideoData = videoData + print("๐ŸŽฅ Live photo video component stored: \(videoData.count) bytes") + } else { + livePhotoVideoData = nil + print("โš ๏ธ Live photo missing video component") + } + + // Clear video properties to ensure image display + currentVideoData = nil + currentVideoURL = nil + + // Start timer for live photo (show as image) + startSlideTimer() + + } catch { + print("โŒ Failed to extract live photo components: \(error)") + // Fallback to treating as regular image + currentImageData = data + currentVideoData = nil + currentVideoURL = nil + livePhotoVideoData = nil + startSlideTimer() + } + + } else if file.isVideo { + // Switching to video: clear image state now + currentImageData = nil + livePhotoVideoData = nil + // Write decrypted data to a temp file to preserve original color space & avoid brightness shifts + do { + let url: URL + if let existing = videoTempFiles[file.id] { + url = existing + } else { + // Extract proper file extension from filename + let fileExtension = file.title.components(separatedBy: ".").last?.lowercased() ?? "mp4" + let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("cast_video_\(file.id)_\(UUID().uuidString).\(fileExtension)") + try data.write(to: tmpURL, options: .atomic) + videoTempFiles[file.id] = tmpURL + url = tmpURL + } + currentVideoURL = url + currentVideoData = nil // release raw data memory + prepareVideoPlayer(url: url) + } catch { + print("โŒ Failed to persist video temp file: \(error)") + currentVideoURL = nil + } + } else { + // Regular image + currentVideoData = nil + currentVideoURL = nil + livePhotoVideoData = nil + currentImageData = data // assign image last for minimal black gap + // Start timer for image slide immediately + startSlideTimer() + } + error = nil + + // If we just transitioned from empty to having content, notify the UI + if wasEmpty { + print("๐Ÿ“ท Slideshow restarted with image data - notifying UI") + NotificationCenter.default.post(name: .slideshowRestarted, object: nil) + } + } + + private func startSlideTimer(withDuration customDuration: TimeInterval? = nil) { + guard let currentFile = currentFile else { return } + // For video slides we rely on actual playback end rather than a fixed timer + if currentFile.isVideo { + slideTimeRemaining = 0 + slideStartTime = nil + return + } + + slideTimer?.invalidate() + let duration = customDuration ?? slideshowConfiguration.duration(for: currentFile) + slideStartTime = Date() + slideTimeRemaining = duration + + // For single photo albums, we still want the timer to fire to maintain the slideshow loop + slideTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self = self, self.isPlaying, !self.isPaused else { return } + self.slideTimeRemaining = 0 + self.slideStartTime = nil + + // For single photos, nextSlide will just restart the timer + await self.nextSlide() + } + } + } + + private func skipToNextSlide() async { + guard !allFiles.isEmpty else { return } + + // Move to next slide + currentFileIndex = (currentFileIndex + 1) % allFiles.count + + // If we've gone through all files and still have errors, stop the slideshow + let maxRetries = allFiles.count + var retryCount = 0 + + while retryCount < maxRetries { + do { + guard let payload = storedCastPayload else { return } + let file = allFiles[currentFileIndex] + + print("๐Ÿ”„ Attempting to load file \(file.id): \(file.title) at index \(currentFileIndex)") + + let decryptedData = try await downloadAndDecryptFileContent( + castPayload: payload, + file: file + ) + + // Success! Update the slide + prefetchCache[currentFileIndex] = decryptedData + await updateCurrentSlide(with: decryptedData, file: file) + + await MainActor.run { + currentSlideIndex = currentFileIndex + slideLoadingProgress = 1.0 + } + + print("โœ… Successfully loaded file \(file.id): \(file.title)") + + // Restart timer if playing + if isPlaying && !isPaused { + startSlideTimer() + } + + // Start prefetching again + startPrefetching() + return + + } catch { + let file = allFiles[currentFileIndex] + print("โŒ Failed to load file \(file.id): \(file.title) - \(error)") + currentFileIndex = (currentFileIndex + 1) % allFiles.count + retryCount += 1 + } + } + + // If we reach here, all files have issues + await MainActor.run { + self.error = "Unable to load any slides. All files may be corrupted or have decryption issues." + } + } + + private func startPrefetching() { + Task { + let prefetchCount = min(3, allFiles.count) + for i in 1...prefetchCount { + let prefetchIndex = (currentFileIndex + i) % allFiles.count + + // Skip if already cached + if prefetchCache[prefetchIndex] != nil { continue } + + guard let payload = storedCastPayload else { continue } + + do { + let file = allFiles[prefetchIndex] + let data = try await downloadAndDecryptFileContent( + castPayload: payload, + file: file + ) + prefetchCache[prefetchIndex] = data + + // Clean up old cache entries + if prefetchCache.count > 5 { + let oldKeys = Array(prefetchCache.keys.sorted().prefix(prefetchCache.count - 5)) + for key in oldKeys { + prefetchCache.removeValue(forKey: key) + } + } + + } catch { + // Silently skip problematic files during prefetching + print("โš ๏ธ Prefetch failed for file \(prefetchIndex), will try on-demand") + continue + } + + // Add delay between prefetch operations + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } + } + } + + // MARK: - Video Playback Management + + private func prepareVideoPlayer(url: URL) { + let playerItem = AVPlayerItem(url: url) + // Observe duration once ready + NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.videoDidFinish() + } + } + let player = AVPlayer(playerItem: playerItem) + player.automaticallyWaitsToMinimizeStalling = true + videoPlayer = player + isVideoPlaying = false + videoCurrentTime = 0 + // Get duration asynchronously for iOS 16+ + Task { @MainActor in + if let duration = try? await playerItem.asset.load(.duration) { + self.videoDuration = CMTimeGetSeconds(duration) + } + } + // Auto-play + playVideo() + startVideoProgressUpdates() + } + + private func startVideoProgressUpdates() { + videoPlayer?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { [weak self] time in + Task { @MainActor in + guard let self = self else { return } + self.videoCurrentTime = CMTimeGetSeconds(time) + if let duration = self.videoPlayer?.currentItem?.duration.seconds, duration.isFinite { + self.videoDuration = duration + } + } + } + } + + func playVideo() { + guard let player = videoPlayer else { return } + player.play() + isVideoPlaying = true + // Ensure slideshow timer paused during video playback + slideTimer?.invalidate() + } + + func pauseVideo() { + videoPlayer?.pause() + isVideoPlaying = false + } + + func seekVideo(by seconds: Double) { + guard let player = videoPlayer else { return } + let current = player.currentTime().seconds + let target = max(0, current + seconds) + let time = CMTime(seconds: target, preferredTimescale: 600) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + } + + @MainActor + private func videoDidFinish() { + isVideoPlaying = false + videoPlayer?.seek(to: .zero) + Task { + await self.nextSlide() + } + } + + // MARK: - Periodic Diff Polling + + @MainActor + private func startPeriodicDiffPolling() { + guard isPeriodicPollingEnabled && storedCastPayload != nil else { return } + + stopPeriodicDiffPolling() // Stop any existing timer + + print("๐Ÿ”„ Starting periodic diff polling (every \(diffPollingInterval)s)") + + diffPollingTimer = Timer.scheduledTimer(withTimeInterval: diffPollingInterval, repeats: true) { [weak self] _ in + Task { + await self?.performPeriodicDiffCheck() + } + } + } + + @MainActor + private func stopPeriodicDiffPolling() { + diffPollingTimer?.invalidate() + diffPollingTimer = nil + print("โน๏ธ Stopped periodic diff polling") + } + + private func performPeriodicDiffCheck() async { + let payload = await MainActor.run { storedCastPayload } + let isEnabled = await MainActor.run { isPeriodicPollingEnabled } + + guard let payload = payload, isEnabled else { return } + + do { + let currentTime = await MainActor.run { lastUpdateTime } + let result = try await fetchFilesBatch(castPayload: payload, sinceTime: currentTime) + + if !result.files.isEmpty { + print("๐Ÿ”„ Periodic poll found \(result.files.count) changes") + + await processDiffBatch(result.files, collectionKey: payload.collectionKey) + + // Only update lastUpdateTime if we actually found items with valid updationTime + await MainActor.run { + if result.latestUpdateTime > self.lastUpdateTime { + print("๐Ÿ“… Updating lastUpdateTime: \(self.lastUpdateTime) โ†’ \(result.latestUpdateTime)") + self.lastUpdateTime = result.latestUpdateTime + } + } + } else { + print("๐Ÿ”„ Periodic poll found no changes since \(currentTime)") + } + + } catch { + if let castError = error as? CastError, + case .serverError(401, _) = castError { + print("๐Ÿ” 401 error during periodic polling - authentication expired") + // handleUnauthorizedError already called in fetchFilesBatch + } else { + print("โš ๏ธ Periodic diff check failed: \(error)") + // Continue polling on other errors + } + } + } + + // MARK: - 401 Error Handling + + @MainActor + private func handleUnauthorizedError() { + // Prevent multiple concurrent auth expiry handling + guard !isHandlingAuthExpiry else { + print("๐Ÿ” Auth expiry already being handled - ignoring duplicate") + return + } + + isHandlingAuthExpiry = true + print("๐Ÿšจ Authentication expired - resetting to pairing mode") + + // CRITICAL: Stop screen saver prevention immediately before any UI transitions + ScreenSaverManager.allowScreenSaver() + + // Stop all ongoing operations + isPeriodicPollingEnabled = false + stopPeriodicDiffPolling() + + // Notify the view model to reset session + NotificationCenter.default.post(name: .authenticationExpired, object: nil) + + // Reset flag after a delay to allow reset to complete + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + await MainActor.run { + self.isHandlingAuthExpiry = false + } + } + } + + // MARK: - Network & Download + + private func downloadImage(castPayload: CastPayload, fileID: Int) async throws -> Data { + // Use preview endpoint for thumbnails suitable for TV display + let url = URL(string: "https://cast-albums.ente.io/preview/?fileID=\(fileID)")! + + var request = URLRequest(url: url) + request.setValue(castPayload.castToken, forHTTPHeaderField: "X-Cast-Access-Token") + request.setValue(castPayload.collectionKey, forHTTPHeaderField: "X-Collection-Key") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CastError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + throw CastError.serverError(httpResponse.statusCode, String(data: data, encoding: .utf8)) + } + + if verboseFileLogging { print("๐Ÿ“ฅ Downloaded \(data.count) bytes for file \(fileID)") } + return data + } + + private func downloadEncryptedFile(castPayload: CastPayload, fileID: Int) async throws -> Data { + // Use the file download endpoint with cast-specific headers + let url = URL(string: "\(castDownloadURL)/?fileID=\(fileID)")! + + var request = URLRequest(url: url) + // Use cast-specific headers like in the diff endpoint + request.setValue(castPayload.castToken, forHTTPHeaderField: "X-Cast-Access-Token") + request.setValue(castPayload.collectionKey, forHTTPHeaderField: "X-Collection-Key") + + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CastError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Condensed error log + let snippet = (String(data: data, encoding: .utf8) ?? "").prefix(160) + print("โŒ Download error [\(httpResponse.statusCode)] fileID=\(fileID): \(snippet)") + if httpResponse.statusCode == 401 { + await handleUnauthorizedError() + throw CastError.serverError(401, "Authentication expired - resetting to pairing mode") + } else { + throw CastError.serverError(httpResponse.statusCode, String(snippet)) + } + } + if verboseFileLogging { print("๐Ÿ“ฅ Successfully downloaded \(data.count) bytes for file \(fileID)") } + return data + } + + private func downloadAndDecryptFileContent(castPayload: CastPayload, file: CastFile) async throws -> Data { + // Check if service is stopping + let stopping = await MainActor.run { isStopping } + if stopping { + throw CastError.networkError("Service is stopping") + } + + // Check cache first + if let cachedData = await getCachedFileContent(fileID: file.id) { + if verboseFileLogging { print("๐Ÿ’พ Using cached content for file \(file.id): \(file.title) (\(cachedData.count) bytes)") } + return cachedData + } + + if verboseFileLogging { print("๐Ÿ” Downloading and decrypting file \(file.id): \(file.title)") } + + // Step 1: Download encrypted file + let encryptedData = try await downloadEncryptedFile(castPayload: castPayload, fileID: file.id) + if verboseFileLogging { print(" ๐Ÿ“ฅ Downloaded \(encryptedData.count) bytes") } + + // Step 2: Decrypt file key using collection key + let fileKey = try decryptFileKey( + encryptedKey: file.encryptedKey, + nonce: file.keyDecryptionNonce, + collectionKey: castPayload.collectionKey + ) + + + // Step 3: Decrypt file content using file key and decryption header + let decryptedData = try decryptFileContent( + encryptedData: encryptedData, + fileKey: fileKey, + decryptionHeader: file.fileDecryptionHeader + ) + + + // Step 4: Cache the decrypted content + await cacheFileContent(fileID: file.id, data: decryptedData) + + return decryptedData + } + + // MARK: - File Decryption Functions + + private func decryptFileMetadata(item: [String: Any], collectionKey: String) async throws -> CastFile? { + guard let id = item["id"] as? Int, + let encryptedKey = item["encryptedKey"] as? String, + let keyDecryptionNonce = item["keyDecryptionNonce"] as? String, + let metadataDict = item["metadata"] as? [String: Any], + let encryptedMetadata = metadataDict["encryptedData"] as? String, + let metadataHeader = metadataDict["decryptionHeader"] as? String, + let fileDict = item["file"] as? [String: Any], + let fileDecryptionHeader = fileDict["decryptionHeader"] as? String else { + print(" โŒ Missing required fields for file \(item["id"] ?? "unknown")") + return nil + } + + do { + // Step 1: Decrypt file key using collection key (SecretBox) + let fileKey = try decryptFileKey( + encryptedKey: encryptedKey, + nonce: keyDecryptionNonce, + collectionKey: collectionKey + ) + if verboseDecryptionLogging { print(" ๐Ÿ”‘ File key decrypted successfully") } + + // Step 2: Decrypt metadata using file key (XChaCha20-Poly1305) + let metadata = try decryptMetadata( + encryptedData: encryptedMetadata, + decryptionHeader: metadataHeader, + fileKey: fileKey + ) + + + // Step 3: Parse decrypted metadata JSON + let fileMetadata = try parseFileMetadata(data: metadata) + + + // Create CastFile with decrypted metadata and decryption info + let isVideo = fileMetadata.fileType == 1 + let isLivePhoto = fileMetadata.fileType == 2 + return CastFile( + id: id, + title: fileMetadata.title, + isVideo: isVideo, + isLivePhoto: isLivePhoto, + encryptedKey: encryptedKey, + keyDecryptionNonce: keyDecryptionNonce, + fileDecryptionHeader: fileDecryptionHeader, + hash: fileMetadata.hash + ) + + } catch { + print(" โŒ Decryption failed: \(error)") + throw error + } + } + + private func decryptFileKey(encryptedKey: String, nonce: String, collectionKey: String) throws -> Data { + // Convert base64 inputs to data + guard let encryptedKeyData = Data(base64Encoded: encryptedKey), + let nonceData = Data(base64Encoded: nonce), + let collectionKeyData = Data(base64Encoded: collectionKey) else { + throw CastError.decryptionError("Invalid base64 in file key decryption") + } + + + // Use EnteCrypto for file key decryption (XSalsa20-Poly1305) + do { + let decryptedFileKey = try EnteCrypto.secretBoxOpen(encryptedKeyData, nonce: nonceData, key: collectionKeyData) + return decryptedFileKey + } catch { + throw CastError.decryptionError("EnteCrypto SecretBox decryption failed for file key: \(error)") + } + } + + private func decryptMetadata(encryptedData: String, decryptionHeader: String, fileKey: Data) throws -> Data { + // Convert base64 inputs to data + guard let encryptedBytes = Data(base64Encoded: encryptedData), + let headerBytes = Data(base64Encoded: decryptionHeader) else { + throw CastError.decryptionError("Invalid base64 in metadata decryption") + } + + print(" ๐Ÿ” XChaCha20: encrypted=\(encryptedBytes.count)b, header=\(headerBytes.count)b, key=\(fileKey.count)b") + + // Use EnteCrypto for XChaCha20-Poly1305 streaming decryption + // This matches the mobile app's CryptoUtil.decryptChaCha implementation + do { + let decryptedData = try EnteCrypto.decryptSecretStream(encryptedData: encryptedBytes, key: fileKey, header: headerBytes) + print(" โœ… Metadata decrypted using EnteCrypto: \(decryptedData.count) bytes") + return decryptedData + } catch { + throw CastError.decryptionError("EnteCrypto XChaCha20-Poly1305 decryption failed for metadata: \(error)") + } + } + + private func decryptFileContent(encryptedData: Data, fileKey: Data, decryptionHeader: String) throws -> Data { + // Convert base64 header to data + guard let headerBytes = Data(base64Encoded: decryptionHeader) else { + throw CastError.decryptionError("Invalid base64 in file decryption header") + } + + if verboseDecryptionLogging { print(" ๐Ÿ” File decryption: encrypted=\(encryptedData.count)b, header=\(headerBytes.count)b, key=\(fileKey.count)b") } + + // Use EnteCrypto for XChaCha20-Poly1305 streaming decryption + do { + return try EnteCrypto.decryptSecretStream(encryptedData: encryptedData, key: fileKey, header: headerBytes) + } catch { + throw CastError.decryptionError("EnteCrypto file content decryption failed: \(error)") + } + } + + private func parseFileMetadata(data: Data) throws -> FileMetadata { + // Parse the decrypted JSON metadata + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CastError.decryptionError("Invalid JSON in decrypted metadata") + } + + let fileType = json["fileType"] as? Int ?? 0 + let title = json["title"] as? String ?? "Unknown" + let creationTime = json["creationTime"] as? Int64 ?? 0 + let modificationTime = json["modificationTime"] as? Int64 ?? 0 + let hash = json["hash"] as? String + + return FileMetadata( + fileType: fileType, + title: title, + creationTime: creationTime, + modificationTime: modificationTime, + hash: hash + ) + } + + // MARK: - Cache Management Helpers + + private func getCachedFileContent(fileID: Int) async -> Data? { + return await fileCache.get(fileID) + } + + private func cacheFileContent(fileID: Int, data: Data) async { + await fileCache.set(fileID, data: data) + } + + private func cleanupExpiredCache(validFileIDs: Set) async { + let stats = await getCacheStats() + print("๐Ÿงน Starting cache cleanup - current cache has \(stats.count) files") + + // Get all currently cached file IDs + let cachedFileIDs = await fileCache.getCachedFileIDs() + + // Remove files that are no longer valid + var removedCount = 0 + for cachedFileID in cachedFileIDs { + if !validFileIDs.contains(cachedFileID) { + await removeCachedFileContent(fileID: cachedFileID) + removedCount += 1 + } + } + + if removedCount > 0 { + let newStats = await getCacheStats() + print("๐Ÿงน Cache cleanup complete - removed \(removedCount) expired files, now \(newStats.count) files (\(newStats.totalSize) bytes)") + } else { + print("๐Ÿงน Cache cleanup complete - no expired files found") + } + } + + private func removeCachedFileContent(fileID: Int) async { + await fileCache.remove(fileID) + } + + private func clearCache() async { + await fileCache.clear() + } + + private func getCacheStats() async -> (count: Int, totalSize: Int) { + return await fileCache.getStats() + } + + func clearAllCache() async { + print("๐Ÿงน Manually clearing all cache") + await fileCache.clear() + } + + func getCacheInfo() async -> String { + let stats = await getCacheStats() + return "Cache: \(stats.count) files, \(String(format: "%.1f", Double(stats.totalSize) / 1024 / 1024)) MB" + } +} + +// MARK: - Live Photo Utilities + +#if os(tvOS) +func extractZipUsingFoundation(zipURL: URL, to destinationURL: URL) throws { + do { + try FileManager.default.unzipItem(at: zipURL, to: destinationURL) + print("โœ… Successfully extracted zip using ZipFoundation") + } catch { + throw CastError.decryptionError("ZipFoundation extraction failed: \(error)") + } +} +#endif + +func extractLivePhotoComponents(from zipData: Data) throws -> LivePhotoComponents { + let tempDirectory = FileManager.default.temporaryDirectory + let zipURL = tempDirectory.appendingPathComponent("livephoto_\(UUID().uuidString).zip") + let extractDirectory = tempDirectory.appendingPathComponent("livephoto_extract_\(UUID().uuidString)") + + defer { + try? FileManager.default.removeItem(at: zipURL) + try? FileManager.default.removeItem(at: extractDirectory) + } + + try zipData.write(to: zipURL) + try FileManager.default.createDirectory(at: extractDirectory, withIntermediateDirectories: true) + + var imageData: Data? + var videoData: Data? + var imagePath: URL? + var videoPath: URL? + + do { + // Use NSTask instead of Process for tvOS compatibility + #if os(macOS) + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + task.arguments = ["-q", zipURL.path, "-d", extractDirectory.path] + try task.run() + task.waitUntilExit() + + if task.terminationStatus != 0 { + throw CastError.decryptionError("unzip command failed with status \(task.terminationStatus)") + } + #elseif os(tvOS) + // For tvOS, we'll implement a simple zip reader using Foundation + try extractZipUsingFoundation(zipURL: zipURL, to: extractDirectory) + #endif + + // Enumerate all extracted files (including nested) because zips may contain a folder structure. + let resourceKeys: [URLResourceKey] = [.isDirectoryKey] + let enumerator = FileManager.default.enumerator(at: extractDirectory, includingPropertiesForKeys: resourceKeys) + + func isLikelyImage(_ url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + return ["jpg", "jpeg", "png", "heic", "heif"].contains(ext) + } + + func isLikelyVideo(_ url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + return ["mov", "mp4", "m4v", "hevc"].contains(ext) + } + + while let fileURL = enumerator?.nextObject() as? URL { + // Skip directories + if (try? fileURL.resourceValues(forKeys: Set(resourceKeys)).isDirectory) == true { continue } + let filename = fileURL.lastPathComponent + + if imageData == nil && isLikelyImage(fileURL) { + imageData = try Data(contentsOf: fileURL) + imagePath = fileURL + print("๐Ÿ“ธ Extracted live photo image: \(filename) (\(imageData?.count ?? 0) bytes)") + } else if videoData == nil && isLikelyVideo(fileURL) { + videoData = try Data(contentsOf: fileURL) + videoPath = fileURL + print("๐ŸŽฅ Extracted live photo video: \(filename) (\(videoData?.count ?? 0) bytes)") + } else { + // Only log unexpected files once both components missing to avoid noise + if imageData == nil || videoData == nil { + print("โ„น๏ธ Ignoring non-component file in live photo zip: \(filename)") + } + } + } + + // Fallback heuristics: Some live photo packages may store assets without extensions or with generic names. + if imageData == nil || videoData == nil { + let contents = try FileManager.default.contentsOfDirectory(at: extractDirectory, includingPropertiesForKeys: nil) + if imageData == nil { + if let guessImage = contents.first(where: { $0.pathExtension.isEmpty }) { // pick first extension-less file as possible image + imageData = try? Data(contentsOf: guessImage) + imagePath = guessImage + if imageData != nil { print("๐Ÿ“ธ Heuristic image pick: \(guessImage.lastPathComponent)") } + } + } + if videoData == nil { + if let guessVideo = contents.first(where: { ["bin", "dat"].contains($0.pathExtension.lowercased()) }) { + videoData = try? Data(contentsOf: guessVideo) + videoPath = guessVideo + if videoData != nil { print("๐ŸŽฅ Heuristic video pick: \(guessVideo.lastPathComponent)") } + } + } + } + + if imageData == nil && videoData == nil { + throw CastError.decryptionError("No valid image or video components found in live photo zip") + } + + return LivePhotoComponents( + imageData: imageData, + videoData: videoData, + imagePath: imagePath, + videoPath: videoPath + ) + + } catch { + print("โŒ Failed to extract live photo components: \(error)") + throw CastError.decryptionError("Failed to extract live photo zip: \(error)") + } +} + diff --git a/mobile/native/ios/Apps/tv/cast/Views/EnteBranding.swift b/mobile/native/ios/Apps/tv/cast/Views/EnteBranding.swift new file mode 100644 index 0000000000..9e048394f2 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Views/EnteBranding.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct EnteBranding: View { + var body: some View { + VStack(alignment: .leading, spacing: -4) { + Text("ente") + .font(FontUtils.gilroyExtraBold(size: 40)) + + .foregroundColor(.black) + + Text("photos") + .font(FontUtils.gilroyBlack(size: 20)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(red: 0/255, green: 179/255, blue: 61/255)) + ) + .rotationEffect(.degrees(-8)) + .offset(x: 20, y: -2) + } + } +} + +#Preview { + ZStack { + Color.white + EnteBranding() + } +} diff --git a/mobile/native/ios/Apps/tv/cast/Views/PairingView.swift b/mobile/native/ios/Apps/tv/cast/Views/PairingView.swift new file mode 100644 index 0000000000..1c09615f23 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Views/PairingView.swift @@ -0,0 +1,182 @@ + +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +struct PairingView: View { + let deviceCode: String + @State private var pulseScale: CGFloat = 1.0 + + var body: some View { + GeometryReader { geometry in + ZStack { + // White background + Color.white + .ignoresSafeArea() + + // Main content - card positioned with custom padding + HStack(spacing: 0) { + // Left padding (1x) + Spacer() + .frame(width: geometry.size.width * 0.05) + + // Green card with content + ZStack { + // Green card background + RoundedRectangle(cornerRadius: 36) + .fill(Color(red: 0/255, green: 179/255, blue: 61/255)) + .shadow(color: Color.black.opacity(0.25), radius: 4, y: 4) + + // Card content + VStack(spacing: 0) { + // Title section + VStack(spacing: -5) { + Text("Ready to") + .font(FontUtils.gilroyExtraBold(size: geometry.size.width * 0.035)) + .foregroundColor(.white) + + Text("Connect?") + .font(FontUtils.gilroyExtraBold(size: geometry.size.width * 0.065)) + .foregroundColor(.white) + } + .padding(.top, geometry.size.height * 0.06) + + Spacer() + .frame(height: geometry.size.height * 0.05) + + // Pairing code box + Text(deviceCode) + .font(.system(size: geometry.size.width * 0.09, weight: .heavy, design: .monospaced)) + .tracking(geometry.size.width * 0.015) + .foregroundColor(.white) + .scaleEffect(pulseScale) + .padding(.horizontal, geometry.size.width * 0.04) + .padding(.vertical, geometry.size.height * 0.025) + .background( + RoundedRectangle(cornerRadius: 32) + .fill(Color(red: 0/255, green: 150/255, blue: 51/255)) + ) + + Spacer() + .frame(height: geometry.size.height * 0.08) + + // Instruction steps + HStack(spacing: geometry.size.width * 0.05) { + InstructionStep( + number: "1", + text: "Open any album", + icon: "photo.on.rectangle.angled", + geometry: geometry + ) + + InstructionStep( + number: "2", + text: "Click Cast button", + icon: "tv", + geometry: geometry + ) + + InstructionStep( + number: "3", + text: "Enter the code", + icon: "keyboard", + geometry: geometry + ) + } + + Spacer() + .frame(height: geometry.size.height * 0.06) + + // Help text + Text("Visit ente.io/cast for help") + .font(FontUtils.interMedium(size: geometry.size.width * 0.012)) + .foregroundColor(.white) + + Spacer() + .frame(height: geometry.size.height * 0.04) + } + + // Ducky positioned at bottom right of the card + VStack { + Spacer() + HStack { + Spacer() + Image("ducky_camera") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: geometry.size.width * 0.27, + height: geometry.size.width * 0.27) + .offset(x: geometry.size.width * 0.18, + y: geometry.size.height * 0.18) + } + } + } + .frame(width: geometry.size.width * 0.8, + height: geometry.size.height * 0.85) + + // Right padding (3x) + Spacer() + .frame(width: geometry.size.width * 0.15) + } + .padding(.top, geometry.size.height * 0.05) // Top padding (1x) + .padding(.bottom, geometry.size.height * 0.05) // Bottom padding (1x) + + // Ente logo in top right corner + VStack { + HStack { + Spacer() + EnteBranding() + .padding(.top, 30) + .padding(.trailing, 30) + } + + Spacer() + } + } + } + .onAppear { + startAnimations() + } + } + + private func startAnimations() { + withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { + pulseScale = 1.05 + } + } +} + +struct InstructionStep: View { + let number: String + let text: String + let icon: String + let geometry: GeometryProxy + + var body: some View { + VStack(spacing: geometry.size.height * 0.015) { + // Circular icon background + ZStack { + Circle() + .fill(Color(red: 0/255, green: 150/255, blue: 51/255)) + .frame(width: geometry.size.width * 0.035, + height: geometry.size.width * 0.035) + + Image(systemName: icon) + .font(.system(size: geometry.size.width * 0.015, weight: .medium)) + .foregroundColor(.white) + } + + // Step text + Text(text) + .font(FontUtils.interMedium(size: geometry.size.width * 0.012)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + } + } +} + +#Preview { + PairingView(deviceCode: "W68GT1") +} diff --git a/mobile/native/ios/Apps/tv/cast/Views/SlideshowView.swift b/mobile/native/ios/Apps/tv/cast/Views/SlideshowView.swift new file mode 100644 index 0000000000..76ef0329f5 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Views/SlideshowView.swift @@ -0,0 +1,677 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +struct SlideshowView: View { + let imageData: Data? + let videoData: Data? + let isVideo: Bool + @ObservedObject var slideshowService: RealSlideshowService + + // Computed property to determine if current file is a live photo + private var isLivePhoto: Bool { + slideshowService.currentFile?.isLivePhoto ?? false + } + + @State private var showControls = false + @State private var controlsTimer: Timer? + @State private var imageScale: CGFloat = 1.0 + @State private var imageOpacity: Double = 1.0 + @State private var previousImageOpacity: Double = 0.0 + @State private var previousImageData: Data? = nil + @State private var actionFeedback: ActionFeedback? = nil + @State private var isPlayingLivePhotoVideo = false + @State private var lastImageBytes: Int = 0 + @State private var imageDecodeFailed: Bool = false + @State private var showToast = false + @State private var toastMessage = "" + @State private var toastIcon = "" + @State private var slideTimeRemaining: TimeInterval = 0 + @State private var lastPauseTime: Date? + @State private var displayImageData: Data? = nil + @State private var preDecodedImage: UIImage? = nil + @State private var previousDecodedImage: UIImage? = nil + + #if os(tvOS) + @FocusState private var isFocused: Bool + #endif + + init(imageData: Data? = nil, videoData: Data? = nil, isVideo: Bool = false, slideshowService: RealSlideshowService) { + self.imageData = imageData + self.videoData = videoData + self.isVideo = isVideo + self.slideshowService = slideshowService + } + + var body: some View { + // โœ… OPTIMIZATION: Use pre-decoded images to avoid decoding during render + let mainUIImage = preDecodedImage + let previousUIImage = previousDecodedImage + + return GeometryReader { geometry in + ZStack { + // Background layer - FIXED VERSION + if let uiImage = mainUIImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .blur(radius: 50) + .scaleEffect(2.2) + .overlay(Color.teal.opacity(0.001)) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .ignoresSafeArea() + } else { + Color.black.ignoresSafeArea() + } + + // Foreground content + if isVideo, let videoData = videoData { + VideoPlayerView( + videoData: videoData, + suggestedFilename: slideshowService.currentFile?.title + ) + .transition(.asymmetric( + insertion: .scale(scale: 1.1).combined(with: .opacity), + removal: .scale(scale: 0.9).combined(with: .opacity) + )) + } else if isLivePhoto && isPlayingLivePhotoVideo, + let liveVideoData = slideshowService.livePhotoVideoData { + VideoPlayerView( + videoData: liveVideoData, + suggestedFilename: slideshowService.currentFile?.title + ) + .transition(.asymmetric( + insertion: .scale(scale: 1.1).combined(with: .opacity), + removal: .scale(scale: 0.9).combined(with: .opacity) + )) + } else if let uiImage = mainUIImage { + // Crossfade image transition + ZStack { + // Previous image (fading out) + if let prevImage = previousUIImage { + Image(uiImage: prevImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(imageScale) + .opacity(previousImageOpacity) + } + + // Current image (fading in) + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaleEffect(imageScale) + .opacity(imageOpacity) + } + .onAppear { + // Pass image data count for logging purposes + animateImageIn(bytes: displayImageData?.count ?? 0, isLive: isLivePhoto) + } + .onDisappear { + imageScale = 1.0 + imageOpacity = 1.0 + previousImageOpacity = 0.0 + isPlayingLivePhotoVideo = false + } + + + } else { + // Check if we have an empty state vs loading state + if let error = slideshowService.error, + (error.contains("No media files available") || error.contains("Empty file list")) { + EmptyState() + } else if slideshowService.totalSlides == 0 && !slideshowService.isPlaying { + // Still loading initial file list + LoadingState() + } else { + // Files exist but current slide is loading + LoadingState() + } + } + + // Overlays + actionFeedbackOverlay + controlsOverlay + toastOverlay +// ambientLightOverlay + } + } + .onChange(of: imageData) { newValue in + if let newData = newValue { + Task { + // Pre-decode image off main thread + let decodedImage = decodedUIImage(from: newData) + + await MainActor.run { + // Store current decoded image as previous for crossfade + previousDecodedImage = preDecodedImage + previousImageData = displayImageData + + // Set decoded image first + preDecodedImage = decodedImage + + // Set up opacity states + if previousDecodedImage != nil { + previousImageOpacity = 1.0 + imageOpacity = 0.0 + } else { + // First image - no crossfade needed + imageOpacity = 1.0 + previousImageOpacity = 0.0 + } + + // Then update display data to trigger view update + displayImageData = newData + animateImageIn(bytes: newData.count, isLive: isLivePhoto) + } + } + } + } + .onTapGesture { Task { await handlePlayPauseAction() } } + .onLongPressGesture { handleLongPressGesture() } + #if os(tvOS) + .focusable() + .focused($isFocused) + .onMoveCommand { direction in Task { await handleDirectionalInput(direction) } } + .onPlayPauseCommand { + if isLivePhoto && slideshowService.livePhotoVideoData != nil { + handleLongPressGesture() + } else { + Task { await handlePlayPauseAction() } + } + } + .onExitCommand { toggleControls() } + #endif + .onAppear { + startControlsTimer() + #if os(tvOS) + isFocused = true + // Backup screen saver prevention at view level + ScreenSaverManager.preventScreenSaver() + #endif + + // CRITICAL FIX: Process initial image data if present and not already processed + // This handles the case where SlideshowView is created with imageData already populated + // (e.g., when transitioning from .connecting to .slideshow state) + // Without this, onChange won't fire and the image won't display + if let initialImageData = imageData, displayImageData == nil && !isVideo { + Task { + let decodedImage = decodedUIImage(from: initialImageData) + + await MainActor.run { + preDecodedImage = decodedImage + imageOpacity = 1.0 + previousImageOpacity = 0.0 + displayImageData = initialImageData + animateImageIn(bytes: initialImageData.count, isLive: isLivePhoto) + } + } + } + } + .onDisappear { + #if os(tvOS) + // Ensure screen saver prevention is disabled when view disappears + ScreenSaverManager.allowScreenSaver() + #endif + } + } + + // MARK: - Overlay Views + + @ViewBuilder + private var actionFeedbackOverlay: some View { + if let feedback = actionFeedback { + ActionFeedbackView(feedback: feedback) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + withAnimation(.easeOut(duration: 0.25)) { actionFeedback = nil } + } + } + } + } + + @ViewBuilder + private var controlsOverlay: some View { + if showControls { + EnhancedControlsOverlay( + isPlaying: slideshowService.isPlaying, + isPaused: slideshowService.isPaused + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity + )) + } + } + + @ViewBuilder + private var toastOverlay: some View { + if showToast { + VStack { + HStack { + AppleStyleToast(icon: toastIcon, message: toastMessage) + Spacer() + } + .padding(.top, 60) + .padding(.leading, 60) + Spacer() + } + } + } + + private func handleLongPressGesture() { + showToast(icon: "livephoto.play", message: "Long press called") + guard isLivePhoto, slideshowService.livePhotoVideoData != nil else { return } + + withAnimation(.easeInOut(duration: 0.3)) { + isPlayingLivePhotoVideo.toggle() + } + + if isPlayingLivePhotoVideo { + slideshowService.pause() + showToast(icon: "livephoto.play", message: "Playing Live Photo") + } else { + slideshowService.resume() + showToast(icon: "livephoto", message: "Live Photo Paused") + } + + // Auto-return to image after video plays + if isPlayingLivePhotoVideo { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + withAnimation(.easeInOut(duration: 0.3)) { + isPlayingLivePhotoVideo = false + } + slideshowService.resume() + } + } + } + + private func handlePlayPauseAction() async { + slideshowService.togglePlayPause() + + await MainActor.run { + // Show toast notification only + if slideshowService.isPaused { + showToast(icon: "pause.fill", message: "Paused") + } else { + showToast(icon: "play.fill", message: "Resumed") + } + } + } + + private func handleDirectionalInput(_ direction: MoveCommandDirection) async { + switch direction { + case .left: + await slideshowService.previousSlide() + // No center feedback for navigation + case .right: + await slideshowService.nextSlide() + // No center feedback for navigation + case .up, .down: + toggleControls() + @unknown default: + break + } + } + + private func toggleControls() { + withAnimation(.easeInOut(duration: 0.4)) { + showControls.toggle() + } + if showControls { + startControlsTimer() + } + } + + private func startControlsTimer() { + controlsTimer?.invalidate() + controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in + withAnimation(.easeInOut(duration: 0.4)) { + showControls = false + } + } + } + + private func showToast(icon: String, message: String) { + toastIcon = icon + toastMessage = message + showToast = true + + // Auto-dismiss after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + showToast = false + } + } + + // MARK: - Image Decoding & Animation + + private func decodedUIImage(from data: Data) -> UIImage? { + imageDecodeFailed = false + if let uiImage = UIImage(data: data) { + // Force decompression by accessing cgImage + if let cg = uiImage.cgImage { + return UIImage(cgImage: cg, scale: uiImage.scale, orientation: uiImage.imageOrientation) + } + return uiImage + } else { + imageDecodeFailed = true + print("โŒ UIImage decode failed (bytes: \(data.count))") + return nil + } + } + + private func animateImageIn(bytes: Int, isLive: Bool) { + lastImageBytes = bytes + imageScale = 1.0 + + // Opacity states are already set up in onChange, just animate the crossfade + withAnimation(.easeInOut(duration: 0.25)) { + imageOpacity = 1.0 + previousImageOpacity = 0.0 + } + + print("๐Ÿ–ผ๏ธ Displaying \(isLive ? "live" : "static") image (\(bytes) bytes)") + + // Clean up previous image data after animation + Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + await MainActor.run { + previousImageData = nil + previousImageOpacity = 0.0 + } + } + } +} + + +// MARK: - Supporting Views and Types + +struct LoadingState: View { + @State private var animationPhase: CGFloat = 0 + @State private var pulseScale: CGFloat = 1.0 + + var body: some View { + VStack(spacing: 40) { + ZStack { + Circle() + .stroke(Color.white.opacity(0.1), lineWidth: 6) + .frame(width: 120, height: 120) + + Circle() + .trim(from: 0, to: 0.3) + .stroke( + LinearGradient( + colors: [Color.blue, Color.purple], + startPoint: .leading, + endPoint: .trailing + ), + style: StrokeStyle(lineWidth: 6, lineCap: .round) + ) + .frame(width: 120, height: 120) + .rotationEffect(.degrees(animationPhase * 360)) + + Circle() + .fill( + RadialGradient( + colors: [ + Color.blue.opacity(0.3), + Color.clear + ], + center: .center, + startRadius: 10, + endRadius: 40 + ) + ) + .frame(width: 80, height: 80) + .scaleEffect(pulseScale) + } + + + } + .onAppear { + withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) { + animationPhase = 1.0 + } + + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + pulseScale = 1.2 + } + } + } +} + +struct EmptyState: View { + @State private var pulseScale: CGFloat = 1.0 + + var body: some View { + VStack(spacing: 40) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.gray.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 20, + endRadius: 60 + ) + ) + .frame(width: 120, height: 120) + .scaleEffect(pulseScale) + + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 50, weight: .ultraLight)) + .foregroundColor(.white.opacity(0.6)) + } + + VStack(spacing: 12) { + Text("No photos in this album") + .font(.system(size: 32, weight: .semibold)) + .foregroundColor(.white) + + Text("Add some photos to start your slideshow") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { + pulseScale = 1.1 + } + } + } +} + +struct EnhancedControlsOverlay: View { + let isPlaying: Bool + let isPaused: Bool + + var body: some View { + VStack { + Spacer() + + HStack { + Spacer() + + VStack(spacing: 20) { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: isPaused ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(isPaused ? .yellow : .green) + + Text(isPaused ? "Paused" : "Playing") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + } + + Text("Apple TV Remote Controls") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + } + + HStack(spacing: 40) { + NavigationHint(icon: "chevron.left.circle.fill", label: "Previous", direction: .leading) + NavigationHint(icon: isPaused ? "play.circle.fill" : "pause.circle.fill", label: isPaused ? "Resume" : "Pause", direction: .center) + NavigationHint(icon: "chevron.right.circle.fill", label: "Next", direction: .trailing) + } + + VStack(spacing: 6) { + Text("โ€ข Touch surface: Play/Pause") + Text("โ€ข Swipe left/right: Navigate slides") + Text("โ€ข Menu button: Show/hide controls") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + .padding(32) + .background( + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color.black.opacity(0.6)) + .background( + RoundedRectangle(cornerRadius: 20).fill( + LinearGradient( + colors: [Color.white.opacity(0.05), Color.white.opacity(0.02)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + } + ) + Spacer() + } + .padding(.bottom, 60) + } + } +} + +struct NavigationHint: View { + let icon: String + let label: String + let direction: HorizontalAlignment + + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 28, weight: .medium)) + + Text(label) + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white.opacity(0.8)) + .frame(minWidth: 80) + } +} + +enum ActionFeedback { + case play, pause, next, previous + + var iconName: String { + switch self { + case .play: "play.circle.fill" + case .pause: "pause.circle.fill" + case .next: "forward.circle.fill" + case .previous: "backward.circle.fill" + } + } + + var title: String { + switch self { + case .play: "Play" + case .pause: "Pause" + case .next: "Next" + case .previous: "Previous" + } + } +} + +struct ActionFeedbackView: View { + let feedback: ActionFeedback + + var body: some View { + VStack(spacing: 8) { + Image(systemName: feedback.iconName) + .font(.system(size: 50, weight: .medium)) + Text(feedback.title) + .font(.system(size: 20, weight: .semibold)) + } + .foregroundColor(.white) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.white.opacity(0.2), lineWidth: 1)) + ) + .transition(.scale.combined(with: .opacity)) + } +} + +struct AppleStyleToast: View { + let icon: String + let message: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white) + + Text(message) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.8)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + ) + .shadow(color: Color.black.opacity(0.3), radius: 8, x: 0, y: 4) + } +} + +// MARK: - Preview + +class MockSlideshowService: ObservableObject { + @Published var currentFile: (title: String, isLivePhoto: Bool)? = (title: "Sample Image", isLivePhoto: false) + @Published var isPlaying: Bool = true + @Published var isPaused: Bool = false + @Published var livePhotoVideoData: Data? = nil + + func togglePlayPause() { isPaused.toggle() } + func nextSlide() async {} + func previousSlide() async {} + func pause() { isPaused = true } + func resume() { isPaused = false } +} + +#Preview { + struct PreviewWrapper: View { + @StateObject private var slideshowService = MockSlideshowService() + + var body: some View { + SlideshowView( + imageData: nil, + slideshowService: slideshowService as! RealSlideshowService + ) + } + } + + return PreviewWrapper() +} diff --git a/mobile/native/ios/Apps/tv/cast/Views/StatusView.swift b/mobile/native/ios/Apps/tv/cast/Views/StatusView.swift new file mode 100644 index 0000000000..5ea62f5b01 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Views/StatusView.swift @@ -0,0 +1,149 @@ +// +// StatusView.swift +// tv +// +// Created by Neeraj Gupta on 28/08/25. +// + +import SwiftUI + +struct StatusView: View { + let status: StatusType + let onRetry: (() -> Void)? + let debugLogs: String? + @State private var animationPhase: CGFloat = 0 + @State private var pulseScale: CGFloat = 1.0 + + enum StatusType { + case loading(String) + case error(String) + case success(String) + case empty(String) + } + + var body: some View { + GeometryReader { geometry in + ZStack { + // White background + Color.white + .ignoresSafeArea() + + // Main content + VStack(spacing: 24) { + Spacer() + + // Status icon - clean display without backgrounds + StatusIcon(status: status) + .padding(.bottom, 16) + + // Title + Text(title) + .font(FontUtils.interSemiBold(size: 42)) + .foregroundColor(.black) + .multilineTextAlignment(.center) + + // Message (if not empty) + if !message.isEmpty { + Text(message) + .font(FontUtils.interRegular(size: 20)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 60) + } + + Spacer() + } + .frame(maxWidth: 900) + .frame(maxWidth: .infinity) + + // Ente branding in top right corner + VStack { + HStack { + Spacer() + EnteBranding() + .padding(.top, 30) + .padding(.trailing, 30) + } + Spacer() + } + } + } + } + + private var title: String { + switch status { + case .loading: + return "Preparing Slideshow" + case .error: + return "Something went wrong" + case .success: + return "All set!" + case .empty: + return "No photos found" + } + } + + private var message: String { + switch status { + case .loading(let message): + return message + case .error(let message): + return message + case .success(let message): + return message + case .empty(let message): + return message + } + } +} + +struct StatusIcon: View { + let status: StatusView.StatusType + + var body: some View { + // Simple icon display without any backgrounds or effects + iconView + .frame(width: 400, height: 240) + } + + @ViewBuilder + private var iconView: some View { + switch status { + case .loading: + Image("ducky_tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 300) + + case .error: + Image("ducky_tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 300) + .opacity(0.8) + + case .success: + Image("ducky_tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 300) + + case .empty: + Image("ducky_tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 300) + .opacity(0.7) + } + } +} + +#Preview { + StatusView(status: .loading("Preparing your slideshow..."), onRetry: nil, debugLogs: nil) + +} + +#Preview { + StatusView(status: .empty("This album has no photos that can be shown here"), onRetry: nil, debugLogs: nil) + +} diff --git a/mobile/native/ios/Apps/tv/cast/Views/VideoPlayerView.swift b/mobile/native/ios/Apps/tv/cast/Views/VideoPlayerView.swift new file mode 100644 index 0000000000..79982d8c35 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/Views/VideoPlayerView.swift @@ -0,0 +1,332 @@ +import SwiftUI +import AVFoundation +import AVKit + +#if canImport(UIKit) +import UIKit +#endif + +struct VideoPlayerView: View { + let videoData: Data + let suggestedFilename: String? + @State private var player: AVPlayer? + @State private var isPlaying = false + @State private var playerItem: AVPlayerItem? + @State private var showToast = false + @State private var toastMessage = "" + @State private var toastIcon = "" + + init(videoData: Data, suggestedFilename: String? = nil) { + self.videoData = videoData + self.suggestedFilename = suggestedFilename + } + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + if let player = player { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { + setupPlayer() + } + .onDisappear { + cleanup() + } + } else { + // Clean loading state + VStack(spacing: 24) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color(red: 29/255, green: 185/255, blue: 84/255))) + .scaleEffect(2.0) + + Text("Loading video...") + .font(.system(size: 24, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + } + } + } + .onAppear { + setupVideoPlayer() + } + .onDisappear { + cleanup() + } + } + + private func setupVideoPlayer() { + Task { + do { + // Extract file extension from suggested filename + let suggestedExtension = suggestedFilename?.components(separatedBy: ".").last?.lowercased() + + // Create temporary file for video data with proper extension + let tempURL = try await createTemporaryVideoFile(from: videoData, suggestedExtension: suggestedExtension) + + await MainActor.run { + // Validate that the video file can be played + let asset = AVURLAsset(url: tempURL) + + // Check if the asset is playable + Task { + let isPlayable = try await asset.load(.isPlayable) + let hasVideoTracks = try await !asset.loadTracks(withMediaType: .video).isEmpty + + await MainActor.run { + if isPlayable && hasVideoTracks { + let playerItem = AVPlayerItem(url: tempURL) + let player = AVPlayer(playerItem: playerItem) + + // Monitor player item status using modern async/await approach + self.monitorPlayerItemStatus(playerItem) + + self.playerItem = playerItem + self.player = player + + setupPlayer() + } else { + print("โŒ Video asset is not playable or has no video tracks") + // Try fallback with different extension + self.tryVideoFallback(originalURL: tempURL) + } + } + } + } + } catch { + print("โŒ Failed to setup video player: \(error)") + await MainActor.run { + // Show error state + self.showErrorState() + } + } + } + } + + private func tryVideoFallback(originalURL: URL) { + // Try creating a new temp file with .mov extension as fallback + Task { + do { + let fallbackURL = originalURL.deletingPathExtension().appendingPathExtension("mov") + try FileManager.default.copyItem(at: originalURL, to: fallbackURL) + + await MainActor.run { + let playerItem = AVPlayerItem(url: fallbackURL) + let player = AVPlayer(playerItem: playerItem) + + self.playerItem = playerItem + self.player = player + + setupPlayer() + } + } catch { + print("โŒ Video fallback also failed: \(error)") + showErrorState() + } + } + } + + private func showErrorState() { + // Could show an error message or placeholder + print("โŒ Unable to play video - showing error state") + } + + private func monitorPlayerItemStatus(_ playerItem: AVPlayerItem) { + // Monitor status using a timer-based approach since we can't use KVO in SwiftUI structs + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + switch playerItem.status { + case .readyToPlay: + print("โœ… Video player ready to play") + timer.invalidate() + case .failed: + if let error = playerItem.error { + print("โŒ Video player failed with error: \(error)") + print("โŒ Error details: \(error.localizedDescription)") + } + timer.invalidate() + Task { @MainActor in + self.showErrorState() + } + case .unknown: + print("โณ Video player status unknown") + // Keep checking + @unknown default: + timer.invalidate() + } + } + } + + private func setupPlayer() { + guard let player = player else { return } + + // Configure player for TV playback + player.actionAtItemEnd = .none + player.automaticallyWaitsToMinimizeStalling = true + + // Set audio session for proper audio handling + #if os(tvOS) + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to set audio session: \(error)") + } + #endif + + // Add observers + setupPlayerObservers() + + // Start playing + player.play() + isPlaying = true + } + + private func setupPlayerObservers() { + guard let player = player, let currentItem = player.currentItem else { return } + + // Add observer for when video ends + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: currentItem, + queue: .main + ) { _ in + // Loop the video + player.seek(to: .zero) + player.play() + } + + // Add observer for player status changes + NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: currentItem, + queue: .main + ) { notification in + if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error { + print("โŒ Video playback failed: \(error)") + } + } + + // Add observer for app lifecycle + NotificationCenter.default.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: .main + ) { _ in + player.pause() + } + + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { _ in + if self.isPlaying { + player.play() + } + } + } + + private func createTemporaryVideoFile(from data: Data, suggestedExtension: String? = nil) async throws -> URL { + let tempDirectory = FileManager.default.temporaryDirectory + let fileExtension = detectVideoExtension(from: data) ?? suggestedExtension ?? "mp4" + let tempURL = tempDirectory.appendingPathComponent("cast_video_\(UUID().uuidString).\(fileExtension)") + + try data.write(to: tempURL) + + // Schedule cleanup of temp file after some time + Task { + try? await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds + try? FileManager.default.removeItem(at: tempURL) + } + + return tempURL + } + + private func detectVideoExtension(from data: Data) -> String? { + let headerBytes = data.prefix(32) // Read more bytes for better detection + + if headerBytes.count >= 4 { + let signature = headerBytes.prefix(4) + + // MP4/MOV formats (most compatible with AVPlayer) + if headerBytes.count >= 12 { + let ftyp = headerBytes.subdata(in: 4..<8) + if ftyp == Data("ftyp".utf8) { + let brand = headerBytes.subdata(in: 8..<12) + if brand == Data("mp41".utf8) || brand == Data("mp42".utf8) || + brand == Data("isom".utf8) || brand == Data("M4V ".utf8) { + print("๐ŸŽฅ Detected MP4 video format") + return "mp4" + } else if brand == Data("qt ".utf8) { + print("๐ŸŽฅ Detected MOV video format") + return "mov" + } + } + } + + // Check for H.264 NAL units (common in MP4) + if headerBytes.count >= 4 { + if signature[0] == 0x00 && signature[1] == 0x00 && signature[2] == 0x00 && signature[3] == 0x01 { + print("๐ŸŽฅ Detected H.264 stream, using MP4 container") + return "mp4" + } + } + + // AVI format (less compatible with tvOS) + if signature == Data("RIFF".utf8) && headerBytes.count >= 12 { + let aviSignature = headerBytes.subdata(in: 8..<12) + if aviSignature == Data("AVI ".utf8) { + print("โš ๏ธ Detected AVI format - may have compatibility issues") + return "avi" + } + } + + // WebM format (limited support on tvOS) + if signature == Data([0x1A, 0x45, 0xDF, 0xA3]) { + print("โš ๏ธ Detected WebM format - may have compatibility issues") + return "webm" + } + + // MKV format + if signature == Data([0x1A, 0x45, 0xDF, 0xA3]) { + print("โš ๏ธ Detected MKV format - may have compatibility issues") + return "mkv" + } + } + + print("๐ŸŽฅ Unknown video format, defaulting to MP4") + return "mp4" + } + + private func cleanup() { + player?.pause() + + // KVO observer removal no longer needed as we use modern async/await approach + + // Remove all notification observers + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) + + // Deactivate audio session + #if os(tvOS) + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("Failed to deactivate audio session: \(error)") + } + #endif + + player = nil + playerItem = nil + isPlaying = false + } +} + +#Preview { + // Create mock video data for preview + let mockData = Data() + VideoPlayerView(videoData: mockData, suggestedFilename: "sample_video.mp4") +} diff --git a/mobile/native/ios/Apps/tv/cast/tvApp.swift b/mobile/native/ios/Apps/tv/cast/tvApp.swift new file mode 100644 index 0000000000..ee8f012b19 --- /dev/null +++ b/mobile/native/ios/Apps/tv/cast/tvApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct tvApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/mobile/native/ios/Apps/tv/tv.xcodeproj/project.pbxproj b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..285eaf2a5f --- /dev/null +++ b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.pbxproj @@ -0,0 +1,635 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + DACC3DAB2E604C9C00DAF993 /* Sodium in Frameworks */ = {isa = PBXBuildFile; productRef = DA72BCAB2E5F87B000D128FF /* Sodium */; }; + DACC3DC92E618D6A00DAF993 /* EnteCrypto in Frameworks */ = {isa = PBXBuildFile; productRef = DACC3DC82E618D6A00DAF993 /* EnteCrypto */; }; + DAF584672E61BB8300F8A08F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = DAF584662E61BB8300F8A08F /* ZIPFoundation */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + DA72BC742E5F87A100D128FF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA72BC592E5F87A000D128FF /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA72BC602E5F87A000D128FF; + remoteInfo = tv; + }; + DA72BC7E2E5F87A100D128FF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA72BC592E5F87A000D128FF /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA72BC602E5F87A000D128FF; + remoteInfo = tv; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + DA72BC612E5F87A000D128FF /* cast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cast.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DA72BC732E5F87A100D128FF /* castTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = castTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DA72BC7D2E5F87A100D128FF /* castUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = castUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + DA01087C2E682336006E79AE /* Exceptions for "cast" folder in "cast" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DA72BC602E5F87A000D128FF /* cast */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DA72BC632E5F87A000D128FF /* cast */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DA01087C2E682336006E79AE /* Exceptions for "cast" folder in "cast" target */, + ); + path = cast; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + DA72BC5E2E5F87A000D128FF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DACC3DAB2E604C9C00DAF993 /* Sodium in Frameworks */, + DACC3DC92E618D6A00DAF993 /* EnteCrypto in Frameworks */, + DAF584672E61BB8300F8A08F /* ZIPFoundation in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC702E5F87A100D128FF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC7A2E5F87A100D128FF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DA72BC582E5F87A000D128FF = { + isa = PBXGroup; + children = ( + DA72BC632E5F87A000D128FF /* cast */, + DACC3DC72E618D6A00DAF993 /* Frameworks */, + DA72BC622E5F87A000D128FF /* Products */, + ); + sourceTree = ""; + }; + DA72BC622E5F87A000D128FF /* Products */ = { + isa = PBXGroup; + children = ( + DA72BC612E5F87A000D128FF /* cast.app */, + DA72BC732E5F87A100D128FF /* castTests.xctest */, + DA72BC7D2E5F87A100D128FF /* castUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DACC3DC72E618D6A00DAF993 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DA72BC602E5F87A000D128FF /* cast */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA72BC872E5F87A100D128FF /* Build configuration list for PBXNativeTarget "cast" */; + buildPhases = ( + DA72BC5D2E5F87A000D128FF /* Sources */, + DA72BC5E2E5F87A000D128FF /* Frameworks */, + DA72BC5F2E5F87A000D128FF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DA72BC632E5F87A000D128FF /* cast */, + ); + name = cast; + packageProductDependencies = ( + DA72BCAB2E5F87B000D128FF /* Sodium */, + DACC3DC82E618D6A00DAF993 /* EnteCrypto */, + DAF584662E61BB8300F8A08F /* ZIPFoundation */, + ); + productName = tv; + productReference = DA72BC612E5F87A000D128FF /* cast.app */; + productType = "com.apple.product-type.application"; + }; + DA72BC722E5F87A100D128FF /* castTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA72BC8A2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castTests" */; + buildPhases = ( + DA72BC6F2E5F87A100D128FF /* Sources */, + DA72BC702E5F87A100D128FF /* Frameworks */, + DA72BC712E5F87A100D128FF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA72BC752E5F87A100D128FF /* PBXTargetDependency */, + ); + name = castTests; + packageProductDependencies = ( + ); + productName = tvTests; + productReference = DA72BC732E5F87A100D128FF /* castTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DA72BC7C2E5F87A100D128FF /* castUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA72BC8D2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castUITests" */; + buildPhases = ( + DA72BC792E5F87A100D128FF /* Sources */, + DA72BC7A2E5F87A100D128FF /* Frameworks */, + DA72BC7B2E5F87A100D128FF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA72BC7F2E5F87A100D128FF /* PBXTargetDependency */, + ); + name = castUITests; + packageProductDependencies = ( + ); + productName = tvUITests; + productReference = DA72BC7D2E5F87A100D128FF /* castUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DA72BC592E5F87A000D128FF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1630; + LastUpgradeCheck = 1630; + TargetAttributes = { + DA72BC602E5F87A000D128FF = { + CreatedOnToolsVersion = 16.3; + }; + DA72BC722E5F87A100D128FF = { + CreatedOnToolsVersion = 16.3; + TestTargetID = DA72BC602E5F87A000D128FF; + }; + DA72BC7C2E5F87A100D128FF = { + CreatedOnToolsVersion = 16.3; + TestTargetID = DA72BC602E5F87A000D128FF; + }; + }; + }; + buildConfigurationList = DA72BC5C2E5F87A000D128FF /* Build configuration list for PBXProject "tv" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DA72BC582E5F87A000D128FF; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */, + DACC3DC62E618D5300DAF993 /* XCLocalSwiftPackageReference "../../Packages/EnteCrypto" */, + DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = DA72BC622E5F87A000D128FF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DA72BC602E5F87A000D128FF /* cast */, + DA72BC722E5F87A100D128FF /* castTests */, + DA72BC7C2E5F87A100D128FF /* castUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DA72BC5F2E5F87A000D128FF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC712E5F87A100D128FF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC7B2E5F87A100D128FF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DA72BC5D2E5F87A000D128FF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC6F2E5F87A100D128FF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA72BC792E5F87A100D128FF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + DA72BC752E5F87A100D128FF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA72BC602E5F87A000D128FF /* cast */; + targetProxy = DA72BC742E5F87A100D128FF /* PBXContainerItemProxy */; + }; + DA72BC7F2E5F87A100D128FF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA72BC602E5F87A000D128FF /* cast */; + targetProxy = DA72BC7E2E5F87A100D128FF /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + DA72BC852E5F87A100D128FF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = SN6GQPQWGV; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TVOS_DEPLOYMENT_TARGET = 18.4; + }; + name = Debug; + }; + DA72BC862E5F87A100D128FF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = SN6GQPQWGV; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = appletvos; + SWIFT_COMPILATION_MODE = wholemodule; + TVOS_DEPLOYMENT_TARGET = 18.4; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DA72BC882E5F87A100D128FF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = cast/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Ente Cast"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.cast; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + }; + name = Debug; + }; + DA72BC892E5F87A100D128FF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = cast/Info.plist; + "INFOPLIST_FILE[sdk=appletvos*]" = ""; + INFOPLIST_KEY_CFBundleDisplayName = "Ente Cast"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.frame.tv.cast; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + }; + name = Release; + }; + DA72BC8B2E5F87A100D128FF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SN6GQPQWGV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.castTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tv"; + TVOS_DEPLOYMENT_TARGET = 18.4; + }; + name = Debug; + }; + DA72BC8C2E5F87A100D128FF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SN6GQPQWGV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tvTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tv"; + TVOS_DEPLOYMENT_TARGET = 18.4; + }; + name = Release; + }; + DA72BC8E2E5F87A100D128FF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SN6GQPQWGV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tv.castUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_TARGET_NAME = tv; + }; + name = Debug; + }; + DA72BC8F2E5F87A100D128FF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SN6GQPQWGV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.ente.photos.tvUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_TARGET_NAME = tv; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DA72BC5C2E5F87A000D128FF /* Build configuration list for PBXProject "tv" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA72BC852E5F87A100D128FF /* Debug */, + DA72BC862E5F87A100D128FF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA72BC872E5F87A100D128FF /* Build configuration list for PBXNativeTarget "cast" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA72BC882E5F87A100D128FF /* Debug */, + DA72BC892E5F87A100D128FF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA72BC8A2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA72BC8B2E5F87A100D128FF /* Debug */, + DA72BC8C2E5F87A100D128FF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA72BC8D2E5F87A100D128FF /* Build configuration list for PBXNativeTarget "castUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA72BC8E2E5F87A100D128FF /* Debug */, + DA72BC8F2E5F87A100D128FF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + DACC3DC62E618D5300DAF993 /* XCLocalSwiftPackageReference "../../Packages/EnteCrypto" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../Packages/EnteCrypto; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jedisct1/swift-sodium.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.1; + }; + }; + DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.19; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DA72BCAB2E5F87B000D128FF /* Sodium */ = { + isa = XCSwiftPackageProductDependency; + package = DA72BCAA2E5F87B000D128FF /* XCRemoteSwiftPackageReference "swift-sodium" */; + productName = Sodium; + }; + DACC3DC82E618D6A00DAF993 /* EnteCrypto */ = { + isa = XCSwiftPackageProductDependency; + productName = EnteCrypto; + }; + DAF584662E61BB8300F8A08F /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = DAF584652E61BB6300F8A08F /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DA72BC592E5F87A000D128FF /* Project object */; +} diff --git a/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..c50fe69999 --- /dev/null +++ b/mobile/native/ios/Apps/tv/tv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "eae515a67704e4d9ab3d2f6ae0b16f46b4842cc00046f3ff034662395dc1b1bf", + "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34", + "version" : "3.15.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342", + "version" : "0.9.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } + } + ], + "version" : 3 +} diff --git a/mobile/native/ios/Packages/EnteCast/Package.resolved b/mobile/native/ios/Packages/EnteCast/Package.resolved new file mode 100644 index 0000000000..c9ce773e64 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + } + ], + "version" : 2 +} diff --git a/mobile/native/ios/Packages/EnteCast/Package.swift b/mobile/native/ios/Packages/EnteCast/Package.swift new file mode 100644 index 0000000000..6b9b45486c --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EnteCast", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "EnteCast", + targets: ["EnteCast"] + ), + ], + dependencies: [ + // TODO: Uncomment when these packages are available: + // .package(path: "../EnteCore"), + // .package(path: "../EnteNetwork"), + // .package(path: "../EnteCrypto"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "EnteCast", + dependencies: [ + // TODO: Uncomment when these packages are available: + // "EnteCore", + // "EnteNetwork", + // "EnteCrypto", + .product(name: "Logging", package: "swift-log"), + ] + ), + .testTarget( + name: "EnteCastTests", + dependencies: ["EnteCast"] + ), + ] +) \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/EnteCast.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/EnteCast.swift new file mode 100644 index 0000000000..3e38da9820 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/EnteCast.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct EnteCast { + public static let version = "1.0.0" + + public init() {} +} + +// Re-export stub types for convenience +// TODO: Replace with actual Ente package exports when available \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastError.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastError.swift new file mode 100644 index 0000000000..a6c545c5d5 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastError.swift @@ -0,0 +1,45 @@ +import Foundation + +public enum CastError: Error, LocalizedError { + case networkError(String) + case decryptionError(String) + case invalidImageData + case imageConversionFailed + case videoProcessingFailed + case fileNotFound + case invalidFileType + case fileSizeTooLarge + case deviceRegistrationFailed + case pairingTimeout + case invalidCastToken + case slideshowStopped + + public var errorDescription: String? { + switch self { + case .networkError(let message): + return "Network error: \(message)" + case .decryptionError(let message): + return "Decryption error: \(message)" + case .invalidImageData: + return "Invalid image data" + case .imageConversionFailed: + return "Failed to convert image" + case .videoProcessingFailed: + return "Failed to process video" + case .fileNotFound: + return "File not found" + case .invalidFileType: + return "Unsupported file type" + case .fileSizeTooLarge: + return "File size too large" + case .deviceRegistrationFailed: + return "Failed to register device" + case .pairingTimeout: + return "Pairing timed out" + case .invalidCastToken: + return "Invalid cast token" + case .slideshowStopped: + return "Slideshow was stopped" + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastFile.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastFile.swift new file mode 100644 index 0000000000..c35faf5945 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastFile.swift @@ -0,0 +1,100 @@ +import Foundation + +public struct CastFileMetadata: Codable, Equatable { + public let fileType: Int + public let title: String + public let creationTime: Int64? + + public init(fileType: Int, title: String, creationTime: Int64? = nil) { + self.fileType = fileType + self.title = title + self.creationTime = creationTime + } +} + +public struct CastFileInfo: Codable, Equatable { + public let fileSize: Int64? + + public init(fileSize: Int64?) { + self.fileSize = fileSize + } +} + +public struct CastFileData: Codable, Equatable { + public let decryptionHeader: String + + public init(decryptionHeader: String) { + self.decryptionHeader = decryptionHeader + } +} + +public struct CastFile: Codable, Equatable { + public let id: FileID + public let metadata: CastFileMetadata + public let info: CastFileInfo? + public let file: CastFileData + public let thumbnail: CastFileData + public let key: String + public let updationTime: Int64 + public let isDeleted: Bool + + public init( + id: FileID, + metadata: CastFileMetadata, + info: CastFileInfo?, + file: CastFileData, + thumbnail: CastFileData, + key: String, + updationTime: Int64, + isDeleted: Bool + ) { + self.id = id + self.metadata = metadata + self.info = info + self.file = file + self.thumbnail = thumbnail + self.key = key + self.updationTime = updationTime + self.isDeleted = isDeleted + } + + public var fileName: String { + return metadata.title + } + + public var fileSize: Int64 { + return info?.fileSize ?? 0 + } + + // File type constants from the web app + public var isImage: Bool { + return metadata.fileType == 0 // FileType.image + } + + public var isVideo: Bool { + return metadata.fileType == 1 // FileType.video + } + + public var isLivePhoto: Bool { + return metadata.fileType == 2 // FileType.livePhoto + } + + public func isEligibleForSlideshow(with configuration: SlideConfiguration) -> Bool { + guard isImage || isLivePhoto || (isVideo && configuration.includeVideos) else { return false } + + // Check file size limits + let maxSize: Int64 = isVideo ? configuration.maxVideoSize : configuration.maxImageSize + guard fileSize <= maxSize else { return false } + + return true + } + + public var fileExtension: String? { + let title = fileName.lowercased() + return URL(fileURLWithPath: title).pathExtension.isEmpty ? nil : URL(fileURLWithPath: title).pathExtension + } + + public var isHEIC: Bool { + return fileExtension == "heic" || fileExtension == "heif" + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastSession.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastSession.swift new file mode 100644 index 0000000000..038177940c --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/CastSession.swift @@ -0,0 +1,88 @@ +import Foundation + +public enum CastSessionState: Equatable { + case idle + case registering + case waitingForPairing(deviceCode: String) + case connected(CastPayload) + case error(String) +} + +public struct CastPayload: Codable, Equatable { + public let collectionID: CollectionID + public let collectionKey: String + public let castToken: String + + public init(collectionID: CollectionID, collectionKey: String, castToken: String) { + self.collectionID = collectionID + self.collectionKey = collectionKey + self.castToken = castToken + } +} + +public struct CastDevice: Codable, Equatable { + public let deviceCode: String + public let publicKey: String + public let privateKey: String + + public init(deviceCode: String, publicKey: String, privateKey: String) { + self.deviceCode = deviceCode + self.publicKey = publicKey + self.privateKey = privateKey + } +} + +@MainActor +public class CastSession: ObservableObject { + @Published public var state: CastSessionState = .idle + @Published public var isActive: Bool = false + + public var deviceCode: String? { + if case .waitingForPairing(let code) = state { + return code + } + return nil + } + + public var payload: CastPayload? { + if case .connected(let payload) = state { + return payload + } + return nil + } + + public init() {} + + public func setState(_ newState: CastSessionState) { + state = newState + isActive = !isIdle + } + + public var isIdle: Bool { + if case .idle = state { + return true + } + return false + } + + public var isWaitingForPairing: Bool { + if case .waitingForPairing = state { + return true + } + return false + } + + public var isConnected: Bool { + if case .connected = state { + return true + } + return false + } + + public var hasError: Bool { + if case .error = state { + return true + } + return false + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/SlideConfiguration.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/SlideConfiguration.swift new file mode 100644 index 0000000000..757bba3fab --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Models/SlideConfiguration.swift @@ -0,0 +1,64 @@ +import Foundation + +public struct SlideConfiguration { + public let imageDuration: TimeInterval + public let videoDuration: TimeInterval + public let useThumbnails: Bool + public let shuffle: Bool + public let maxImageSize: Int64 + public let maxVideoSize: Int64 + public let includeVideos: Bool + + // Enhanced prefetch settings + public let prefetchCount: Int + public let maxCacheSize: Int + public let prefetchDelay: TimeInterval + public let enablePrefetching: Bool + + public init( + imageDuration: TimeInterval = 12.0, + videoDuration: TimeInterval = 30.0, + useThumbnails: Bool = false, + shuffle: Bool = true, + maxImageSize: Int64 = 100 * 1024 * 1024, // 100MB + maxVideoSize: Int64 = 500 * 1024 * 1024, // 500MB + includeVideos: Bool = true, + prefetchCount: Int = 3, + maxCacheSize: Int = 5, + prefetchDelay: TimeInterval = 0.5, + enablePrefetching: Bool = true + ) { + self.imageDuration = imageDuration + self.videoDuration = videoDuration + self.useThumbnails = useThumbnails + self.shuffle = shuffle + self.maxImageSize = maxImageSize + self.maxVideoSize = maxVideoSize + self.includeVideos = includeVideos + self.prefetchCount = prefetchCount + self.maxCacheSize = maxCacheSize + self.prefetchDelay = prefetchDelay + self.enablePrefetching = enablePrefetching + } + + public func duration(for file: CastFile) -> TimeInterval { + return file.isVideo ? videoDuration : imageDuration + } + + public static let `default` = SlideConfiguration() + + // TV-optimized configuration (use thumbnails for better performance) + public static let tvOptimized = SlideConfiguration( + imageDuration: 8.0, // Faster progression for TV viewing + videoDuration: 25.0, + useThumbnails: true, // Better performance on Apple TV + shuffle: true, + maxImageSize: 50 * 1024 * 1024, // 50MB for TV + maxVideoSize: 200 * 1024 * 1024, // 200MB for TV + includeVideos: true, + prefetchCount: 3, // Prefetch next 3 slides + maxCacheSize: 5, // Keep 5 slides in cache + prefetchDelay: 0.5, // 500ms delay between prefetch operations + enablePrefetching: true // Enable prefetching for smooth transitions + ) +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastFileService.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastFileService.swift new file mode 100644 index 0000000000..7e5dffb693 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastFileService.swift @@ -0,0 +1,184 @@ +import Foundation +import Logging + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AVFoundation) +import AVFoundation +#endif + +public class CastFileService { + private let castGateway: CastGateway + private let logger = Logger(label: "CastFileService") + + public init(castGateway: CastGateway) { + self.castGateway = castGateway + } + + // MARK: - File Discovery + + public func fetchAllFiles(castToken: String) async throws -> [CastFile] { + logger.info("Fetching all cast files") + + var allFiles: [FileID: CastFile] = [:] + var sinceTime: Int64 = 0 + + while true { + let diff = try await castGateway.getDiff(sinceTime: sinceTime) + + guard !diff.diff.isEmpty else { break } + + for change in diff.diff { + sinceTime = max(sinceTime, change.updationTime) + + if change.isDeleted { + allFiles.removeValue(forKey: change.id) + } else { + // Convert network model to our model + // Note: This is a simplified conversion + // In practice, we'd need to decrypt the file metadata + let castFile = CastFile( + id: change.id, + metadata: CastFileMetadata(fileType: 0, title: "File \(change.id)"), // TODO: Get real metadata + info: CastFileInfo(fileSize: nil), + file: CastFileData(decryptionHeader: ""), // TODO: Get real headers + thumbnail: CastFileData(decryptionHeader: ""), + key: "", // TODO: Get real key + updationTime: change.updationTime, + isDeleted: false + ) + allFiles[change.id] = castFile + } + } + + if !diff.hasMore { break } + } + + let files = Array(allFiles.values) + logger.info("Fetched \(files.count) files") + return files + } + + // MARK: - File Download + + public func downloadFile( + castFile: CastFile, + castToken: String, + useThumbnail: Bool = false + ) async throws -> Data { + logger.info("Downloading file \(castFile.id), thumbnail: \(useThumbnail)") + + let encryptedData: Data + let decryptionHeader: String + + if useThumbnail { + encryptedData = try await castGateway.getThumbnail(fileID: castFile.id) + decryptionHeader = castFile.thumbnail.decryptionHeader + } else { + encryptedData = try await castGateway.getFile(fileID: castFile.id) + decryptionHeader = castFile.file.decryptionHeader + } + + // Decrypt the file data + let decryptedData = try await decryptFileData( + encryptedData: encryptedData, + key: castFile.key, + decryptionHeader: decryptionHeader + ) + + // Process the file based on its type + return try await processFileData(decryptedData, castFile: castFile) + } + + private func decryptFileData( + encryptedData: Data, + key: String, + decryptionHeader: String + ) async throws -> Data { + do { + logger.info("Decrypting file data: \(encryptedData.count) bytes encrypted") + + // Use proper stream decryption + let decryptedData = try CryptoUtil.decryptFileData( + encryptedData: encryptedData, + base64Key: key, + base64Header: decryptionHeader + ) + + logger.info("Successfully decrypted \(encryptedData.count) bytes to \(decryptedData.count) bytes") + return decryptedData + } catch { + logger.error("Stream decryption failed: \(error)") + throw CastError.decryptionError(error.localizedDescription) + } + } + + // MARK: - File Processing + + public func processFileData(_ data: Data, castFile: CastFile) async throws -> Data { + if castFile.isImage { + return try await processImageData(data, castFile: castFile) + } else if castFile.isVideo { + return try await processVideoData(data, castFile: castFile) + } else if castFile.isLivePhoto { + return try await processLivePhotoData(data, castFile: castFile) + } + + return data + } + + private func processImageData(_ data: Data, castFile: CastFile) async throws -> Data { + #if canImport(UIKit) + guard let image = UIImage(data: data) else { + throw CastError.invalidImageData + } + + // Convert HEIC to JPEG for better tvOS compatibility + if castFile.isHEIC { + guard let jpegData = image.jpegData(compressionQuality: 0.85) else { + throw CastError.imageConversionFailed + } + return jpegData + } + + return data + #else + return data + #endif + } + + private func processVideoData(_ data: Data, castFile: CastFile) async throws -> Data { + // For videos, we might want to extract a thumbnail or process metadata + // For now, return the data as-is + return data + } + + private func processLivePhotoData(_ data: Data, castFile: CastFile) async throws -> Data { + // For live photos, extract the still image component + #if canImport(UIKit) + guard let image = UIImage(data: data) else { + throw CastError.invalidImageData + } + + // Convert to JPEG for slideshow display + guard let jpegData = image.jpegData(compressionQuality: 0.85) else { + throw CastError.imageConversionFailed + } + return jpegData + #else + return data + #endif + } + + // MARK: - File Filtering + + public func filterEligibleFiles(_ files: [CastFile], configuration: SlideConfiguration) -> [CastFile] { + return files.filter { $0.isEligibleForSlideshow(with: configuration) } + } + + public func shuffleFiles(_ files: [CastFile]) -> [CastFile] { + return files.shuffled() + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastPairingService.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastPairingService.swift new file mode 100644 index 0000000000..663ca3312e --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/CastPairingService.swift @@ -0,0 +1,140 @@ +import Foundation +import Logging +import EnteCrypto +import EnteNetwork + +public class CastPairingService { + private let castGateway: CastGateway + private let logger = Logger(label: "CastPairingService") + private var pollingTask: Task? + + public init(castGateway: CastGateway) { + self.castGateway = castGateway + } + + deinit { + stopPolling() + } + + // MARK: - Device Registration + + public func registerDevice() async throws -> CastDevice { + logger.info("Starting device registration") + + // Generate real X25519 keypair for this session + let keyPair = EnteCrypto.generateKeyPair() + logger.debug("Generated keypair for cast session") + + // Register with the server to get a device code + let response = try await castGateway.registerDevice() + + logger.info("Device registered successfully with code: \(response.code)") + + return CastDevice( + deviceCode: response.code, + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey + ) + } + + // MARK: - Polling for Cast Payload + + public func startPolling( + device: CastDevice, + onPayloadReceived: @escaping (CastPayload) -> Void, + onError: @escaping (Error) -> Void + ) { + logger.info("Starting polling for device code: \(device.deviceCode)") + + stopPolling() // Stop any existing polling + + pollingTask = Task { + await pollForPayload( + device: device, + onPayloadReceived: onPayloadReceived, + onError: onError + ) + } + } + + public func stopPolling() { + logger.info("Stopping polling") + pollingTask?.cancel() + pollingTask = nil + } + + private func pollForPayload( + device: CastDevice, + onPayloadReceived: @escaping (CastPayload) -> Void, + onError: @escaping (Error) -> Void + ) async { + let pollInterval: TimeInterval = 2.0 // Poll every 2 seconds + let maxAttempts = 150 // 5 minutes total (150 * 2 seconds) + var attempts = 0 + + while !Task.isCancelled && attempts < maxAttempts { + do { + attempts += 1 + + // Check if payload is available + if let payload = try await checkForPayload(device: device) { + logger.info("Payload received successfully") + await MainActor.run { + onPayloadReceived(payload) + } + return + } + + // Wait before next poll + try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + + } catch { + logger.error("Error during polling: \(error)") + await MainActor.run { + onError(error) + } + return + } + } + + if attempts >= maxAttempts { + logger.warning("Polling timeout reached") + await MainActor.run { + onError(EnteError.configurationError("Pairing timeout")) + } + } + } + + private func checkForPayload(device: CastDevice) async throws -> CastPayload? { + // This implements the web app's getCastPayload function + // Check if encrypted payload is available and decrypt it + + do { + let castData = try await castGateway.getCastData(deviceCode: device.deviceCode) + + // The response from museum contains encrypted payload data + // We need to implement decryption similar to web implementation + + // TODO: Once the server API returns encrypted data, implement decryption: + // 1. Get encrypted payload from castData + // 2. Decrypt using our private key: EnteCrypto.sealedBoxOpen(encryptedData, publicKey, privateKey) + // 3. Parse the decrypted JSON to get CastPayload + + // For now, convert from network model to our model + // This assumes the server returns the raw data (not encrypted) + return CastPayload( + collectionID: castData.collectionID, + collectionKey: "temp-key", // This should come from decrypted payload + castToken: castData.castToken + ) + } catch { + // If the error is "not found" or similar, it means no payload yet + if let enteError = error as? EnteError, + case .serverError(let code, _) = enteError, + code == 404 { + return nil // No payload available yet + } + throw error // Re-throw other errors + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/SlideshowService.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/SlideshowService.swift new file mode 100644 index 0000000000..c9a53ca9b0 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Services/SlideshowService.swift @@ -0,0 +1,348 @@ +import Foundation +import Logging + +public class SlideshowService: ObservableObject { + @Published public var currentImageData: Data? + @Published public var currentVideoData: Data? + @Published public var currentFile: CastFile? + @Published public var isLoading: Bool = false + @Published public var error: String? + + // Enhanced state management + @Published public var isPlaying: Bool = false + @Published public var isPaused: Bool = false + @Published public var slideLoadingProgress: Double = 0.0 + @Published public var currentSlideIndex: Int = 0 + @Published public var totalSlides: Int = 0 + + private let fileService: CastFileService + private let configuration: SlideConfiguration + private let logger = Logger(label: "SlideshowService") + + private var slideshowTask: Task? + private var prefetchTask: Task? + private var currentFiles: [CastFile] = [] + private var currentIndex: Int = 0 + private var castToken: String = "" + private var slideTimer: Timer? + + // Prefetching cache + private var prefetchCache: [Int: Data] = [:] + private let prefetchQueue = DispatchQueue(label: "slideshow.prefetch", qos: .utility) + + public init(fileService: CastFileService, configuration: SlideConfiguration = .tvOptimized) { + self.fileService = fileService + self.configuration = configuration + } + + deinit { + stop() + } + + // MARK: - Enhanced Controls + + public func pause() { + logger.info("Pausing slideshow") + slideTimer?.invalidate() + slideTimer = nil + + Task { @MainActor in + isPaused = true + isPlaying = false + } + } + + public func resume() { + guard !currentFiles.isEmpty else { return } + logger.info("Resuming slideshow") + + Task { @MainActor in + isPaused = false + isPlaying = true + } + + startSlideTimer() + } + + public func togglePlayPause() { + if isPaused || !isPlaying { + resume() + } else { + pause() + } + } + + // MARK: - Slideshow Control + + public func start(castToken: String) async { + logger.info("Starting slideshow") + + self.castToken = castToken + await MainActor.run { + isLoading = true + error = nil + isPlaying = false + isPaused = false + slideLoadingProgress = 0.0 + currentSlideIndex = 0 + } + + stop() // Stop any existing slideshow + + slideshowTask = Task { + await runSlideshow() + } + } + + public func stop() { + logger.info("Stopping slideshow") + slideshowTask?.cancel() + slideshowTask = nil + prefetchTask?.cancel() + prefetchTask = nil + slideTimer?.invalidate() + slideTimer = nil + + Task { @MainActor in + isPlaying = false + isPaused = false + } + + // Clear prefetch cache + prefetchCache.removeAll() + } + + private func runSlideshow() async { + do { + // Fetch all files + let allFiles = try await fileService.fetchAllFiles(castToken: castToken) + let eligibleFiles = fileService.filterEligibleFiles(allFiles, configuration: configuration) + + guard !eligibleFiles.isEmpty else { + await MainActor.run { + isLoading = false + error = "No media files found in this album" + } + return + } + + currentFiles = configuration.shuffle ? fileService.shuffleFiles(eligibleFiles) : eligibleFiles + currentIndex = 0 + + await MainActor.run { + isLoading = false + totalSlides = currentFiles.count + currentSlideIndex = 0 + isPlaying = true + } + + // Display the first slide + await displaySlideAtIndex(currentIndex) + + // Start prefetching + startPrefetching() + + // Start the timer for auto-advancement + startSlideTimer() + + } catch { + logger.error("Slideshow error: \(error)") + await MainActor.run { + isLoading = false + self.error = error.localizedDescription + } + } + } + + private func displayNextSlide() async { + guard !currentFiles.isEmpty else { return } + + let file = currentFiles[currentIndex] + + do { + let fileData = try await fileService.downloadFile( + castFile: file, + castToken: castToken, + useThumbnail: configuration.useThumbnails + ) + + await MainActor.run { + currentFile = file + + if file.isVideo { + currentVideoData = fileData + currentImageData = nil + } else { + currentImageData = fileData + currentVideoData = nil + } + + error = nil + } + + // Move to next file (loop back to start when at end) + currentIndex = (currentIndex + 1) % currentFiles.count + + // If we've completed a full cycle, reshuffle if enabled + if currentIndex == 0 && configuration.shuffle { + currentFiles = fileService.shuffleFiles(currentFiles) + } + + } catch { + logger.error("Failed to display slide for file \(file.id): \(error)") + // Skip this file and move to the next one + currentIndex = (currentIndex + 1) % currentFiles.count + } + } + + // MARK: - Manual Navigation + + public func nextSlide() async { + guard !currentFiles.isEmpty else { return } + currentIndex = (currentIndex + 1) % currentFiles.count + await displaySlideAtIndex(currentIndex, updateTimer: true) + } + + public func previousSlide() async { + guard !currentFiles.isEmpty else { return } + currentIndex = currentIndex > 0 ? currentIndex - 1 : currentFiles.count - 1 + await displaySlideAtIndex(currentIndex, updateTimer: true) + } + + // MARK: - Timer Management + + private func startSlideTimer() { + guard let currentFile = currentFile else { return } + + slideTimer?.invalidate() + let duration = configuration.duration(for: currentFile) + + slideTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self = self, self.isPlaying, !self.isPaused else { return } + await self.advanceToNextSlide() + } + } + } + + private func advanceToNextSlide() async { + guard !currentFiles.isEmpty else { return } + + currentIndex = (currentIndex + 1) % currentFiles.count + + // If we've completed a full cycle, reshuffle if enabled + if currentIndex == 0 && configuration.shuffle { + currentFiles = fileService.shuffleFiles(currentFiles) + } + + await displaySlideAtIndex(currentIndex) + + if isPlaying && !isPaused { + startSlideTimer() + } + } + + // MARK: - Enhanced Display Logic + + private func displaySlideAtIndex(_ index: Int, updateTimer: Bool = false) async { + guard index >= 0, index < currentFiles.count else { return } + + let file = currentFiles[index] + + do { + // Update loading state + await MainActor.run { + slideLoadingProgress = 0.0 + currentSlideIndex = index + } + + // Check if we have prefetched data + let fileData: Data + if let cachedData = prefetchCache[index] { + fileData = cachedData + await MainActor.run { slideLoadingProgress = 1.0 } + } else { + fileData = try await fileService.downloadFile( + castFile: file, + castToken: castToken, + useThumbnail: configuration.useThumbnails + ) + await MainActor.run { slideLoadingProgress = 1.0 } + } + + await MainActor.run { + currentFile = file + + if file.isVideo { + currentVideoData = fileData + currentImageData = nil + } else { + currentImageData = fileData + currentVideoData = nil + } + + error = nil + } + + // Start/restart timer if needed + if updateTimer && isPlaying && !isPaused { + startSlideTimer() + } + + } catch { + logger.error("Failed to display slide for file \(file.id): \(error)") + await MainActor.run { + self.error = "Failed to load image: \(error.localizedDescription)" + slideLoadingProgress = 0.0 + } + } + } + + // MARK: - Prefetching + + private func startPrefetching() { + prefetchTask?.cancel() + prefetchTask = Task { + await performPrefetching() + } + } + + private func performPrefetching() async { + let prefetchCount = min(3, currentFiles.count) + + for i in 1...prefetchCount { + let prefetchIndex = (currentIndex + i) % currentFiles.count + + // Skip if already cached + if prefetchCache[prefetchIndex] != nil { continue } + + let file = currentFiles[prefetchIndex] + + do { + let fileData = try await fileService.downloadFile( + castFile: file, + castToken: castToken, + useThumbnail: configuration.useThumbnails + ) + + // Store in cache + prefetchCache[prefetchIndex] = fileData + + // Clean up old cache entries (keep only 5 recent entries) + if prefetchCache.count > 5 { + let oldKeys = Array(prefetchCache.keys.sorted().prefix(prefetchCache.count - 5)) + for key in oldKeys { + prefetchCache.removeValue(forKey: key) + } + } + + } catch { + // Silently fail for prefetching - we'll try again when needed + continue + } + + // Add delay between prefetch operations to avoid overwhelming the system + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Stubs/EnteStubs.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Stubs/EnteStubs.swift new file mode 100644 index 0000000000..00b4aac124 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Stubs/EnteStubs.swift @@ -0,0 +1,125 @@ +// Temporary stub implementations for Ente dependencies +// TODO: Remove when actual Ente packages are integrated + +import Foundation + +// MARK: - EnteCore Stubs + +public typealias FileID = Int +public typealias CollectionID = Int + +// MARK: - EnteNetwork Stubs + +public protocol CastGateway { + func getDiff(sinceTime: Int64) async throws -> DiffResponse + func getFile(fileID: FileID) async throws -> Data + func getThumbnail(fileID: FileID) async throws -> Data + func registerDevice() async throws -> DeviceRegistrationResponse + func getCastData(deviceCode: String) async throws -> CastData +} + +public struct DiffResponse: Codable { + public let diff: [FileChange] + public let hasMore: Bool + + public init(diff: [FileChange], hasMore: Bool) { + self.diff = diff + self.hasMore = hasMore + } +} + +public struct FileChange: Codable { + public let id: FileID + public let updationTime: Int64 + public let isDeleted: Bool + + public init(id: FileID, updationTime: Int64, isDeleted: Bool) { + self.id = id + self.updationTime = updationTime + self.isDeleted = isDeleted + } +} + +public struct DeviceRegistrationResponse: Codable { + public let code: String + + public init(code: String) { + self.code = code + } +} + +public struct CastData: Codable { + public let collectionID: Int + public let castToken: String + + public init(collectionID: Int, castToken: String) { + self.collectionID = collectionID + self.castToken = castToken + } +} + +public enum EnteError: Error { + case serverError(Int, String) + case configurationError(String) +} + +// Mock implementation of CastGateway +public class MockCastGateway: CastGateway { + public init() {} + + public func getDiff(sinceTime: Int64) async throws -> DiffResponse { + // Return empty diff for now + return DiffResponse(diff: [], hasMore: false) + } + + public func getFile(fileID: FileID) async throws -> Data { + // Return mock file data + return "Mock file data".data(using: .utf8) ?? Data() + } + + public func getThumbnail(fileID: FileID) async throws -> Data { + // Return mock thumbnail data + return "Mock thumbnail data".data(using: .utf8) ?? Data() + } + + public func registerDevice() async throws -> DeviceRegistrationResponse { + // Generate a mock device code + let code = String((1...6).map { _ in String(Int.random(in: 0...9)) }.joined()) + return DeviceRegistrationResponse(code: code) + } + + public func getCastData(deviceCode: String) async throws -> CastData { + // Mock implementation - throw 404 for first few calls, then return data + throw EnteError.serverError(404, "Not found") + } +} + +// MARK: - EnteCrypto Implementation (using proper stream decryption) + +import EnteCrypto + +public enum CryptoUtil { + public static func decryptChaChaBase64(data: Data, key: Data, header: Data) throws -> Data { + // Use proper stream decryption from EnteCrypto + return try EnteCrypto.decryptFileStream(encryptedData: data, key: key, header: header) + } + + /// Decrypts file data using stream decryption with base64 encoded parameters + /// This matches the format used by the mobile app and server + public static func decryptFileData( + encryptedData: Data, + base64Key: String, + base64Header: String + ) throws -> Data { + guard let keyData = Data(base64Encoded: base64Key), + let headerData = Data(base64Encoded: base64Header) else { + throw CastError.decryptionError("Invalid base64 key or header") + } + + return try EnteCrypto.decryptFileStream( + encryptedData: encryptedData, + key: keyData, + header: headerData + ) + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/FileDownloader.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/FileDownloader.swift new file mode 100644 index 0000000000..4a33a670af --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/FileDownloader.swift @@ -0,0 +1,124 @@ +import Foundation +import Logging + +public class FileDownloader { + private let logger = Logger(label: "FileDownloader") + private let urlSession: URLSession + private let cache = NSCache() + + public init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + setupCache() + } + + private func setupCache() { + cache.countLimit = 10 // Keep up to 10 images in memory + cache.totalCostLimit = 50 * 1024 * 1024 // 50MB total cache size + } + + // MARK: - Download with Caching + + public func downloadWithCache( + url: URL, + cacheKey: String? = nil + ) async throws -> Data { + let key = cacheKey ?? url.absoluteString + let cacheKey = NSString(string: key) + + // Check cache first + if let cachedData = cache.object(forKey: cacheKey) { + logger.debug("Cache hit for key: \(key)") + return cachedData as Data + } + + // Download if not cached + logger.debug("Cache miss, downloading: \(url)") + let (data, response) = try await urlSession.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw FileDownloaderError.downloadFailed + } + + // Cache the result + let cost = data.count + cache.setObject(NSData(data: data), forKey: cacheKey, cost: cost) + + return data + } + + // MARK: - Background Download + + public func downloadInBackground(url: URL) -> AsyncStream { + AsyncStream { continuation in + let task = urlSession.downloadTask(with: url) { localURL, response, error in + if let error = error { + continuation.yield(.failed(error)) + continuation.finish() + return + } + + guard let localURL = localURL, + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + continuation.yield(.failed(FileDownloaderError.downloadFailed)) + continuation.finish() + return + } + + do { + let data = try Data(contentsOf: localURL) + continuation.yield(.completed(data)) + continuation.finish() + } catch { + continuation.yield(.failed(error)) + continuation.finish() + } + } + + task.resume() + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + // MARK: - Cache Management + + public func clearCache() { + logger.info("Clearing download cache") + cache.removeAllObjects() + } + + public func removeCachedData(for key: String) { + cache.removeObject(forKey: NSString(string: key)) + } +} + +// MARK: - Download Progress + +public enum DownloadProgress { + case progress(ByteCountFormatter.CountStyle) + case completed(Data) + case failed(Error) +} + +// MARK: - Errors + +public enum FileDownloaderError: Error, LocalizedError { + case downloadFailed + case invalidResponse + case cacheError + + public var errorDescription: String? { + switch self { + case .downloadFailed: + return "Download failed" + case .invalidResponse: + return "Invalid server response" + case .cacheError: + return "Cache operation failed" + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/ImageProcessor.swift b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/ImageProcessor.swift new file mode 100644 index 0000000000..7aee95728c --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Sources/EnteCast/Utils/ImageProcessor.swift @@ -0,0 +1,167 @@ +import Foundation +import UniformTypeIdentifiers +import ImageIO +import CoreGraphics + +#if canImport(UIKit) +import UIKit +#endif + +public struct ImageProcessor { + + // MARK: - MIME Type Detection + + public static func detectMIMEType(from data: Data, fileName: String = "") -> String? { + // Try to detect from data first + if let mimeType = detectMIMETypeFromData(data) { + return mimeType + } + + // Fallback to file extension + return detectMIMETypeFromExtension(fileName) + } + + private static func detectMIMETypeFromData(_ data: Data) -> String? { + guard data.count >= 12 else { return nil } + + let bytes = data.prefix(12) + + // Check for common image formats + if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { + return "image/jpeg" + } + if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { + return "image/png" + } + if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { + return "image/gif" + } + if bytes.starts(with: [0x57, 0x45, 0x42, 0x50]) { + return "image/webp" + } + // HEIF/HEIC detection + if bytes.count >= 12 { + let slice = bytes[4..<12] + if slice.starts(with: [0x66, 0x74, 0x79, 0x70]) { // "ftyp" + let subtype = bytes.suffix(4) + if subtype.starts(with: [0x68, 0x65, 0x69, 0x63]) { // "heic" + return "image/heic" + } + if subtype.starts(with: [0x68, 0x65, 0x69, 0x66]) { // "heif" + return "image/heif" + } + } + } + + return nil + } + + private static func detectMIMETypeFromExtension(_ fileName: String) -> String? { + let url = URL(fileURLWithPath: fileName) + let pathExtension = url.pathExtension.lowercased() + + switch pathExtension { + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + case "heic": + return "image/heic" + case "heif": + return "image/heif" + default: + return nil + } + } + + // MARK: - Image Processing + + public static func processImage(_ data: Data, fileName: String) async throws -> Data { + guard let mimeType = detectMIMEType(from: data, fileName: fileName) else { + throw ImageProcessorError.unsupportedFormat + } + + // Handle HEIC/HEIF conversion + if mimeType == "image/heic" || mimeType == "image/heif" { + return try await convertHEICToJPEG(data) + } + + // For other formats, return as-is for now + return data + } + + // MARK: - HEIC Conversion + + private static func convertHEICToJPEG(_ data: Data) async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let convertedData = try convertHEICToJPEGSync(data) + continuation.resume(returning: convertedData) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func convertHEICToJPEGSync(_ data: Data) throws -> Data { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + throw ImageProcessorError.conversionFailed + } + + let mutableData = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutableData, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + throw ImageProcessorError.conversionFailed + } + + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: 0.8 + ] + + CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + throw ImageProcessorError.conversionFailed + } + + return mutableData as Data + } + + // MARK: - Live Photo Processing + + public static func extractImageFromLivePhoto(_ data: Data, fileName: String) throws -> (Data, String) { + // Live photo processing is complex and would require parsing the container format + // For now, return the data as-is and let the system handle it + return (data, fileName) + } +} + +// MARK: - Errors + +public enum ImageProcessorError: Error, LocalizedError { + case unsupportedFormat + case conversionFailed + case processingFailed + + public var errorDescription: String? { + switch self { + case .unsupportedFormat: + return "Unsupported image format" + case .conversionFailed: + return "Failed to convert image format" + case .processingFailed: + return "Failed to process image" + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCast/Tests/EnteCastTests/EnteCastTests.swift b/mobile/native/ios/Packages/EnteCast/Tests/EnteCastTests/EnteCastTests.swift new file mode 100644 index 0000000000..97a3414d3d --- /dev/null +++ b/mobile/native/ios/Packages/EnteCast/Tests/EnteCastTests/EnteCastTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import EnteCast + +final class EnteCastTests: XCTestCase { + + func testCastFileEligibility() { + let imageFile = CastFile( + id: FileID(1), + metadata: CastFileMetadata(fileType: 0, title: "image.jpg"), // Image type + info: CastFileInfo(fileSize: 1024 * 1024), // 1MB + file: CastFileData(decryptionHeader: "header"), + thumbnail: CastFileData(decryptionHeader: "thumb-header"), + key: "key", + updationTime: 123456, + isDeleted: false + ) + + XCTAssertTrue(imageFile.isImage) + XCTAssertTrue(imageFile.isEligibleForSlideshow) + + let largeFile = CastFile( + id: FileID(2), + metadata: CastFileMetadata(fileType: 0, title: "large.jpg"), + info: CastFileInfo(fileSize: 200 * 1024 * 1024), // 200MB - too large + file: CastFileData(decryptionHeader: "header"), + thumbnail: CastFileData(decryptionHeader: "thumb-header"), + key: "key", + updationTime: 123456, + isDeleted: false + ) + + XCTAssertFalse(largeFile.isEligibleForSlideshow) + } + + func testSlideConfiguration() { + let defaultConfig = SlideConfiguration.default + XCTAssertEqual(defaultConfig.duration, 12.0) + XCTAssertTrue(defaultConfig.shuffle) + + let tvConfig = SlideConfiguration.tvOptimized + XCTAssertTrue(tvConfig.useThumbnails) + XCTAssertEqual(tvConfig.maxFileSize, 50 * 1024 * 1024) + } + + func testCastPayload() { + let payload = CastPayload( + collectionID: CollectionID(123), + collectionKey: "test-key", + castToken: "test-token" + ) + + XCTAssertEqual(payload.collectionID, CollectionID(123)) + XCTAssertEqual(payload.collectionKey, "test-key") + XCTAssertEqual(payload.castToken, "test-token") + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCore/.gitignore b/mobile/native/ios/Packages/EnteCore/.gitignore new file mode 100644 index 0000000000..0023a53406 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCore/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mobile/native/ios/Packages/EnteCore/Package.resolved b/mobile/native/ios/Packages/EnteCore/Package.resolved new file mode 100644 index 0000000000..1e52e1ff8f --- /dev/null +++ b/mobile/native/ios/Packages/EnteCore/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + } + ], + "version" : 2 +} diff --git a/mobile/native/ios/Packages/EnteCore/Package.swift b/mobile/native/ios/Packages/EnteCore/Package.swift new file mode 100644 index 0000000000..96298b6e6c --- /dev/null +++ b/mobile/native/ios/Packages/EnteCore/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EnteCore", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "EnteCore", + targets: ["EnteCore"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-tagged.git", from: "0.10.0"), + ], + targets: [ + .target( + name: "EnteCore", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Tagged", package: "swift-tagged"), + ] + ), + .testTarget( + name: "EnteCoreTests", + dependencies: ["EnteCore"] + ), + ] +) diff --git a/mobile/native/ios/Packages/EnteCore/Sources/EnteCore/EnteCore.swift b/mobile/native/ios/Packages/EnteCore/Sources/EnteCore/EnteCore.swift new file mode 100644 index 0000000000..d851ffd773 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCore/Sources/EnteCore/EnteCore.swift @@ -0,0 +1,128 @@ +import Foundation +import Tagged + +// MARK: - Basic Types + +/// Strongly typed User ID +public typealias UserID = Tagged +public enum UserIDTag {} + +/// Strongly typed File ID +public typealias FileID = Tagged +public enum FileIDTag {} + +/// Strongly typed Collection ID +public typealias CollectionID = Tagged +public enum CollectionIDTag {} + +// MARK: - App Types + +public enum EnteApp: String, CaseIterable, Codable { + case photos = "photos" + case auth = "auth" + case locker = "locker" + case cast = "cast" + + public var displayName: String { + switch self { + case .photos: return "Ente Photos" + case .auth: return "Ente Auth" + case .locker: return "Ente Locker" + case .cast: return "Ente Cast" + } + } + + public var packageIdentifier: String { + switch self { + case .photos: return "io.ente.photos" + case .auth: return "io.ente.auth" + case .locker: return "io.ente.locker" + case .cast: return "io.ente.photos.cast" + } + } +} + +// MARK: - Platform Detection + +public enum EntePlatform: String { + case iOS = "ios" + case tvOS = "tvos" + case macOS = "macos" + + public static var current: EntePlatform { + #if os(iOS) + return .iOS + #elseif os(tvOS) + return .tvOS + #elseif os(macOS) + return .macOS + #endif + } + + public var userAgent: String { + switch self { + case .iOS: return "EnteNative-iOS" + case .tvOS: return "EnteNative-tvOS" + case .macOS: return "EnteNative-macOS" + } + } +} + +// MARK: - Encrypted Data + +public struct EncryptedData: Codable, Equatable { + public let data: String + public let nonce: String? + + public init(data: String, nonce: String? = nil) { + self.data = data + self.nonce = nonce + } +} + +// MARK: - Error Types + +public enum EnteError: Error, Equatable { + case networkError(String) + case authenticationError(String) + case cryptographicError(String) + case configurationError(String) + case invalidResponse + case unauthorized + case serverError(Int, String?) + + public var localizedDescription: String { + switch self { + case .networkError(let message): + return "Network error: \(message)" + case .authenticationError(let message): + return "Authentication error: \(message)" + case .cryptographicError(let message): + return "Cryptographic error: \(message)" + case .configurationError(let message): + return "Configuration error: \(message)" + case .invalidResponse: + return "Invalid server response" + case .unauthorized: + return "Unauthorized access" + case .serverError(let code, let message): + return "Server error (\(code)): \(message ?? "Unknown error")" + } + } +} + +// MARK: - Common Response Types + +public struct EmptyResponse: Codable { + public init() {} +} + +public struct ErrorResponse: Codable { + public let code: String? + public let message: String + + public init(code: String? = nil, message: String) { + self.code = code + self.message = message + } +} diff --git a/mobile/native/ios/Packages/EnteCore/Tests/EnteCoreTests/EnteCoreTests.swift b/mobile/native/ios/Packages/EnteCore/Tests/EnteCoreTests/EnteCoreTests.swift new file mode 100644 index 0000000000..60bef3b345 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCore/Tests/EnteCoreTests/EnteCoreTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import EnteCore + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/mobile/native/ios/Packages/EnteCrypto/.gitignore b/mobile/native/ios/Packages/EnteCrypto/.gitignore new file mode 100644 index 0000000000..0023a53406 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mobile/native/ios/Packages/EnteCrypto/CRYPTO_SPEC.md b/mobile/native/ios/Packages/EnteCrypto/CRYPTO_SPEC.md new file mode 100644 index 0000000000..5f6c03eb70 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/CRYPTO_SPEC.md @@ -0,0 +1,324 @@ +# Ente Cryptographic Operations Specification + +## Overview + +This document specifies the cryptographic operations used in the Ente ecosystem, with particular focus on the tvOS/Swift implementation via the EnteCrypto package. All crypto operations use libsodium for consistency across platforms (Mobile, Web, CLI, tvOS). + +## Core Algorithms & Libraries + +### Primary Dependencies +- **libsodium**: Cross-platform cryptographic library (via swift-sodium) +- **CryptoKit**: Apple's cryptographic framework (X25519 key generation only) +- **Swift Crypto**: Additional cryptographic primitives + +### Algorithm Suite +- **Key Exchange**: X25519 Elliptic Curve Diffie-Hellman +- **Symmetric Encryption**: XSalsa20Poly1305 (secretbox), XChaCha20Poly1305 (secretstream) +- **Anonymous Encryption**: X25519 + XSalsa20Poly1305 (sealed box) +- **Hashing**: BLAKE2b (64-byte output) +- **Key Derivation**: Argon2id, libsodium KDF + +--- + +## Authentication & Key Derivation + +### Password-Based Key Derivation + +**Purpose**: Convert user password into encryption keys +**Algorithm**: Argon2id +**Implementation**: `EnteCrypto.deriveArgonKey()` + +```swift +// Parameters (configurable based on device capability) +memLimit: Int // Memory limit in bytes +opsLimit: Int // Time/CPU cost parameter +salt: String // Base64-encoded 16-byte salt +output: 32 bytes // keyEncryptionKey (KEK) +``` + +**Cross-Platform Notes**: +- Mobile/Web: argon2-browser, go-argon2 +- tvOS: swift-sodium pwHash.hash() +- All platforms produce identical outputs for same parameters + +### Login Key Derivation + +**Purpose**: Derive authentication key from KEK for SRP +**Algorithm**: libsodium KDF (crypto_kdf_derive_from_key) +**Implementation**: `EnteCrypto.deriveLoginKey()` + +```swift +input: keyEncryptionKey (32 bytes) +kdf_id: 1 +context: "loginctx" (8 bytes) +output: 16 bytes (first half of 32-byte derived key) +``` + +--- + +## Secure Remote Password (SRP) + +**Purpose**: Zero-knowledge password authentication +**Implementation**: `SRPClient` class +**Parameters**: +- Group: 4096-bit safe prime (RFC 5054) +- Hash: SHA-256 +- Generator: 5 + +### Authentication Flow +1. Client generates private key `a`, computes public key `A = g^a mod N` +2. Server responds with public key `B`, salt +3. Client computes shared secret `S` using password-derived `x` +4. Mutual authentication via evidence exchange (M1, M2) +5. Session key derived as `SHA-256(S)` + +--- + +## Cast System Cryptography + +### Key Generation + +**Purpose**: Generate keypair for cast device pairing +**Algorithm**: X25519 ECDH +**Implementation**: `EnteCrypto.generateCastKeyPair()` + +```swift +// Uses CryptoKit for X25519 generation (compatible with libsodium) +privateKey: Curve25519.KeyAgreement.PrivateKey +publicKey: derived from privateKey +output: base64-encoded key pair +``` + +### Cast Payload Encryption/Decryption + +**Purpose**: Secure transmission of collection metadata from mobile to tvOS +**Algorithm**: X25519 + XSalsa20Poly1305 (NaCl sealed box) +**Implementation**: `EnteCrypto.decryptCastPayload()` + +**Mobile Client (Dart)**: +```dart +final encPayload = CryptoUtil.sealSync( + CryptoUtil.base642bin(base64Encode(payload.codeUnits)), + CryptoUtil.base642bin(publicKey) +); +``` + +**tvOS Client (Swift)**: +```swift +let decryptedBytes = sodium.box.open( + anonymousCipherText: cipherText, + recipientPublicKey: publicKey, + recipientSecretKey: privateKey +) +``` + +**Payload Structure**: +```json +{ + "collectionID": 12345, + "castToken": "authentication-token", + "collectionKey": "base64-encoded-collection-key" +} +``` + +--- + +## File Encryption System + +### File Key Decryption + +**Purpose**: Decrypt file-specific encryption keys +**Algorithm**: XSalsa20Poly1305 (libsodium secretbox) +**Implementation**: `EnteCrypto.secretBoxOpen()` + +```swift +input: encryptedKey (base64) + nonce (base64) + collectionKey (32 bytes) +output: fileKey (32 bytes) +``` + +### File Content & Metadata Decryption + +**Purpose**: Decrypt actual file data and metadata +**Algorithm**: XChaCha20Poly1305 (libsodium secretstream) +**Implementation**: `EnteCrypto.decryptSecretStream()` + +```text +Inputs: + fileKey: 32 bytes (random; derived via key hierarchy) + header: 24 bytes (emitted once by secretstream initPush; MUST be stored) + cipher: Concatenation of encrypted chunks (each chunk = plaintextChunk + 17 bytes overhead) +Output: + Plaintext file bytes (exact original size) +``` + +#### File (NOT thumbnail) Encryption Format +All full-size files use libsodium secretstream XChaCha20-Poly1305 with FIXED PLAINTEXT CHUNK SIZE: +* Plaintext chunk size: 4,194,304 bytes (4 MiB) except final chunk (smaller or equal) +* Per-chunk overhead: 17 bytes (secretstream MAC + internal framing) +* Cipher chunk size (non-final): 4,194,321 bytes (4 MiB + 17) +* Final chunk: plaintext_len + 17 (tag = FINAL) + +Encryption steps (producer): +1. Generate `fileKey` (32 random bytes) if not already present. +2. `state, header = initPush(fileKey)`; persist `header` alongside encrypted data record. +3. For each 4 MiB plaintext slice (streamed from disk): + - Tag = `MESSAGE` for all but last; `FINAL` for last slice. + - `cipherChunk = push(state, plaintextSlice, tag)` (cipherChunk length = sliceLen + 17) + - Append `cipherChunk` to output blob. +4. Store: `header` (24B), concatenated cipher blob, original size metadata (optional but useful for validations), and hash (BLAKE2b) if required. + +Decryption steps (consumer): +1. Retrieve `fileKey`, `header`, full `cipher` blob. +2. Initialize: `state = initPull(header, fileKey)`. +3. Iterate over the cipher blob in fixed sized chunks of 4,194,321 bytes (cipherChunkSize) until remaining < cipherChunkSize; treat remainder as final chunk. +4. For each chunk: `plaintext, tag = pull(state, cipherChunk)`; append `plaintext`. +5. Expect `FINAL` tag on the last chunk and no trailing bytes after a `FINAL`. +6. Authentication failure (pull returns nil) โ‡’ abort: report `decryptionFailed`. + +Notes: +* Each chunk MAC authenticates sequence + content; modification or truncation causes pull failure. +* If in future producers change chunk size, the decrypter MUST add negotiation metadata (e.g. store chunk size) or attempt adaptive boundary detection BEFORE deploying change. +* Metadata blobs (small) MAY still be single-chunk (then cipher length = plaintext + 17, tag = FINAL). Logic above still applies. + +--- + +## Hash Verification System + +### BLAKE2b Hashing + +**Purpose**: Verify file content integrity +**Algorithm**: BLAKE2b (crypto_generichash) +**Output Length**: 64 bytes (512 bits) +**Implementation**: `EnteCrypto.computeBlake2bHash()` + +```swift +input: file_content (Data) +process: sodium.genericHash.hash(message, outputLength: 64) +output: hex_string (128 characters) +``` + +### Cross-Platform Hash Verification + +**Challenge**: Server stores hashes as base64, client computes as hex +**Solution**: Dual-format comparison in `EnteCrypto.verifyFileHash()` + +```swift +// 1. Try direct hex comparison +if computedHex == expectedHash { return true } + +// 2. Try base64โ†’hex conversion +if let base64Data = Data(base64Encoded: expectedHash) { + let expectedHex = base64Data.hexString + if computedHex == expectedHex { return true } +} +``` + +--- + +## Platform Consistency Matrix + +| Operation | Mobile (Dart) | Web (TypeScript) | CLI (Go) | tvOS (Swift) | +|-----------|---------------|------------------|----------|--------------| +| **Key Derivation** | argon2-browser | argon2-browser | go-argon2 | swift-sodium | +| **SRP Auth** | custom | custom | custom | SRPClient | +| **Cast Payload** | sealSync() | boxSeal() | SealedBoxOpen() | box.open() | +| **File Key** | secretBox | secretBox | SecretBoxOpen() | secretBox.open() | +| **File Content** | decryptChaCha() | decryptStreamBytes() | DecryptFile() | secretStream.xchacha20poly1305 | +| **Hash** | blake2b (64B) | blake2b (64B) | blake2b (64B) | genericHash (64B) | + +## Security Properties + +### Cryptographic Guarantees +1. **Forward Secrecy**: Cast uses ephemeral X25519 keypairs +2. **Zero Knowledge**: Server cannot decrypt payloads or file contents +3. **Authenticated Encryption**: All operations use AEAD ciphers +4. **Key Hierarchy**: `masterKey โ†’ collectionKey โ†’ fileKey โ†’ content` +5. **Integrity Verification**: BLAKE2b hashes validate authenticity + +### Implementation Security +1. **Constant-Time Operations**: libsodium provides timing-safe implementations +2. **Memory Safety**: Swift automatic memory management + libsodium secure allocators +3. **Key Zeroization**: libsodium handles secure key deletion +4. **Side-Channel Resistance**: Hardware-accelerated crypto where available + +--- + +## Error Handling + +### CryptoError Types +- `invalidSalt`: Base64 decoding or format errors +- `invalidParameters`: Wrong key/nonce lengths, malformed input +- `invalidKeyLength`: Key size validation failures +- `derivationFailed`: Argon2id, KDF, or random generation failures +- `decryptionFailed`: Authentication tag verification failures +- `encryptionFailed`: Encryption operation failures + +### Error Recovery +1. **Cast Operations**: Fall back to demo mode on crypto failures +2. **File Operations**: Skip files with decryption/verification errors +3. **Authentication**: Clear stored credentials and restart flow +4. **Network**: Retry with exponential backoff for transient failures + +--- + +## Performance Considerations + +### Optimization Strategies +1. **Streaming**: Use secretstream for large files (>1MB) +2. **Hardware Acceleration**: Leverage ARM64 crypto extensions on Apple TV +3. **Memory Management**: Process files in chunks to limit peak memory +4. **Caching**: Reuse decrypted collection keys within session +5. **Precomputation**: Generate ephemeral keys during app startup + +### Resource Limits +- **Memory**: Argon2id memLimit tuned per device capability +- **Time**: Argon2id opsLimit balanced for UX vs security +- **Storage**: No persistent key storage (session-only) +- **Network**: Cast payload <1KB, efficient for real-time transmission + +--- + +## Testing & Validation + +### Cross-Platform Test Vectors +1. **Key Derivation**: Same password/salt โ†’ identical KEK across platforms +2. **Cast Payload**: Mobile-encrypted โ†’ tvOS-decrypted โ†’ identical JSON +3. **File Decryption**: Server file โ†’ tvOS client โ†’ verified hash match +4. **Hash Computation**: Same content โ†’ identical BLAKE2b across platforms + +### Security Testing +1. **Static Analysis**: SwiftLint crypto-specific rules +2. **Dynamic Analysis**: Memory leak detection during crypto operations +3. **Fuzzing**: Malformed input handling in decrypt operations +4. **Side-Channel**: Timing analysis of key derivation and comparison + +--- + +## Implementation Notes + +### Swift-Specific Considerations +1. **libsodium Integration**: Use swift-sodium wrapper for cross-platform compatibility +2. **Memory Management**: Data types automatically zeroed by ARC +3. **Error Handling**: Typed errors with localized descriptions +4. **Concurrency**: All crypto operations are synchronous (libsodium requirement) + +### tvOS Platform Constraints +1. **No Persistent Storage**: Keys exist only in memory during session +2. **Limited Input**: QR code scanning for device pairing +3. **Network Only**: All crypto material received via cast protocol +4. **Performance**: ARM64 hardware acceleration available + +--- + +## Version History + +**v1.0 (August 2025)**: Initial implementation with working cast functionality +- Cast payload decryption (sealed box) +- File key/content decryption (secretbox/secretstream) +- BLAKE2b hash verification (64-byte, dual-format) +- X25519 key generation (CryptoKit + libsodium compatible) +- Cross-platform compatibility verified + +--- + +*This specification ensures cryptographic consistency across all Ente client platforms while maintaining the security guarantees of the zero-knowledge architecture.* \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCrypto/Package.resolved b/mobile/native/ios/Packages/EnteCrypto/Package.resolved new file mode 100644 index 0000000000..ada667fa67 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "334e682869394ee239a57dbe9262bff3cd9495bd", + "version" : "3.14.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342", + "version" : "0.9.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + } + ], + "version" : 2 +} diff --git a/mobile/native/ios/Packages/EnteCrypto/Package.swift b/mobile/native/ios/Packages/EnteCrypto/Package.swift new file mode 100644 index 0000000000..d6a4e3b131 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EnteCrypto", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "EnteCrypto", + targets: ["EnteCrypto"] + ), + ], + dependencies: [ + .package(path: "../EnteCore"), + .package(path: "../EnteNetwork"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/jedisct1/swift-sodium.git", from: "0.9.0"), + .package(url: "https://github.com/attaswift/BigInt.git", from: "5.3.0"), + ], + targets: [ + .target( + name: "EnteCrypto", + dependencies: [ + "EnteCore", + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Sodium", package: "swift-sodium"), + .product(name: "BigInt", package: "BigInt"), + ] + ), + .testTarget( + name: "EnteCryptoTests", + dependencies: [ + "EnteCrypto", + "EnteCore", + "EnteNetwork" + ] + ), + ] +) diff --git a/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/EnteCrypto.swift b/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/EnteCrypto.swift new file mode 100644 index 0000000000..309892af9a --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/EnteCrypto.swift @@ -0,0 +1,389 @@ +// EnteCrypto - Cryptographic operations for Ente native apps +// Based on CLI implementation with libsodium + +import Foundation +import EnteCore +import Crypto +import CryptoKit +import Sodium +import Logging + +// MARK: - Constants (mirrors CLI crypto.go) + +public struct CryptoConstants { + // Login key derivation constants - matches CLI crypto.go exactly + public static let loginSubKeyLength: UInt32 = 32 // CLI: loginSubKeyLen = 32 + public static let loginSubKeyId: UInt64 = 1 // CLI: loginSubKeyId = 1 + public static let loginSubKeyContext = "loginctx" // CLI: loginSubKeyContext = "loginctx" + + // BLAKE2b constants + + public static let cryptoKDFBlake2bBytesMax: UInt32 = 64 + public static let cryptoGenerichashBlake2bSaltBytes = 16 + public static let cryptoGenerichashBlake2bPersonalBytes = 16 + + // Box constants + public static let boxSealBytes = 48 // 32 for ephemeral public key + 16 for MAC + + // Default key lengths + public static let keyEncryptionKeyLength = 32 + public static let argonSaltLength = 16 +} + +// MARK: - Crypto Errors + +public enum CryptoError: Error, Equatable { + case invalidSalt + case invalidParameters + case invalidKeyLength + case derivationFailed + case decryptionFailed + case singleShotDecryptionFailed + case encryptionFailed + case invalidNonce + case invalidTag + + public var localizedDescription: String { + switch self { + case .invalidSalt: + return "Invalid salt format" + case .invalidParameters: + return "Invalid cryptographic parameters" + case .invalidKeyLength: + return "Invalid key length" + case .derivationFailed: + return "Key derivation failed" + case .singleShotDecryptionFailed: + return "Single shot decryption failed" + case .decryptionFailed: + return "Decryption failed" + case .encryptionFailed: + return "Encryption failed" + case .invalidNonce: + return "Invalid nonce" + case .invalidTag: + return "Invalid authentication tag" + } + } +} + +// MARK: - Main Crypto Class + +public class EnteCrypto { + private static let logger = Logger(label: "EnteCrypto") + + // MARK: - Key Derivation + + /// Derives a key using Argon2id - mirrors CLI DeriveArgonKey + public static func deriveArgonKey( + password: String, + salt: String, + memLimit: Int, + opsLimit: Int + ) throws -> Data { + logger.debug("Deriving Argon key with memLimit: \(memLimit), opsLimit: \(opsLimit)") + + guard memLimit >= 1024 && opsLimit >= 1 else { + throw CryptoError.invalidParameters + } + + // Decode salt from base64 + guard let saltData = Data(base64Encoded: salt) else { + throw CryptoError.invalidSalt + } + + let passwordData = password.data(using: .utf8)! + + // โœ… SOLUTION FOUND: Swift Sodium expects memLimit in bytes and produces + // the same result as Go when we pass the original byte value directly! + // Go: argon2.IDKey(..., uint32(memLimit/1024), ...) uses KB internally + // Swift: sodium.pwHash.hash(..., memLimit: memLimit, ...) uses bytes directly + // Both produce the same final keyEncKey result! + + logger.debug("Using Argon2id with memLimit=\(memLimit) bytes, opsLimit=\(opsLimit)") + + let sodium = Sodium() + guard let derivedKey = sodium.pwHash.hash( + outputLength: CryptoConstants.keyEncryptionKeyLength, + passwd: passwordData.bytes, + salt: saltData.bytes, + opsLimit: opsLimit, + memLimit: memLimit, // Use original bytes value - produces same result as Go! + alg: .Argon2ID13 + ) else { + throw CryptoError.derivationFailed + } + + let result = Data(derivedKey) + logger.debug("Derived keyEncKey successfully") + return result + } + + /// Derives login key from keyEncKey - exactly matching web libsodium KDF implementation + /// This loginKey acts as user provided password during SRP authentication + public static func deriveLoginKey(keyEncKey: Data) throws -> Data { + logger.debug("Deriving login key using libsodium KDF (matching web implementation)") + + // Use the exact same libsodium KDF as web implementation: + // deriveSubKeyBytes(kek, 32, 1, "loginctx") and take first 16 bytes + // Web: sodium.crypto_kdf_derive_from_key(32, 1, "loginctx", kek) + let sodium = Sodium() + + guard let derivedKey = sodium.keyDerivation.derive( + secretKey: keyEncKey.bytes, + index: CryptoConstants.loginSubKeyId, // 1 + length: Int(CryptoConstants.loginSubKeyLength), // 32 + context: CryptoConstants.loginSubKeyContext // "loginctx" + ) else { + throw CryptoError.derivationFailed + } + + logger.debug("Libsodium KDF derived key (32 bytes)") + + // Return the first 16 bytes (same as web: kekSubKeyBytes.slice(0, 16)) + let result = Data(derivedKey.prefix(16)) + logger.debug("Login key derived (16 bytes)") + return result + } + + // MARK: - Key Generation + + /// Generates a new X25519 keypair for cast pairing + /// Returns base64 encoded public and private keys + public static func generateKeyPair() -> (publicKey: String, privateKey: String) { + logger.debug("Generating X25519 keypair for cast pairing") + + let sodium = Sodium() + let keyPair = sodium.box.keyPair()! + + let publicKeyB64 = Data(keyPair.publicKey).base64EncodedString() + let privateKeyB64 = Data(keyPair.secretKey).base64EncodedString() + + logger.debug("Generated keypair - public: \(publicKeyB64.prefix(16))...") + + return (publicKey: publicKeyB64, privateKey: privateKeyB64) + } + + /// SealedBox encryption for sending data to cast receiver + public static func sealedBoxSeal(_ plainText: Data, recipientPublicKey: Data) throws -> Data { + guard recipientPublicKey.count == 32 else { + throw CryptoError.invalidParameters + } + + let sodium = Sodium() + guard let encrypted = sodium.box.seal( + message: plainText.bytes, + recipientPublicKey: recipientPublicKey.bytes + ) else { + throw CryptoError.encryptionFailed + } + + return Data(encrypted) + } + + // MARK: - Encryption/Decryption Operations + + /// SecretBox encryption - mirrors CLI SecretBoxOpen + public static func secretBoxOpen(_ cipherText: Data, nonce: Data, key: Data) throws -> Data { + guard nonce.count == 24, key.count == 32 else { + throw CryptoError.invalidParameters + } + + let sodium = Sodium() + guard let decrypted = sodium.secretBox.open( + authenticatedCipherText: cipherText.bytes, + secretKey: key.bytes, + nonce: nonce.bytes + ) else { + throw CryptoError.decryptionFailed + } + + return Data(decrypted) + } + + /// SealedBox decryption - standard libsodium sealed box + public static func sealedBoxOpen(_ cipherText: Data, publicKey: Data, secretKey: Data) throws -> Data { + guard publicKey.count == 32, secretKey.count == 32 else { + throw CryptoError.invalidParameters + } + + let sodium = Sodium() + guard let decrypted = sodium.box.open( + anonymousCipherText: cipherText.bytes, + recipientPublicKey: publicKey.bytes, + recipientSecretKey: secretKey.bytes + ) else { + throw CryptoError.decryptionFailed + } + + return Data(decrypted) + } + + /// ChaCha20-Poly1305 encryption - standard libsodium AEAD + public static func encryptChaCha20Poly1305(data: Data, key: Data) throws -> (cipherText: Data, nonce: Data) { + guard key.count == 32 else { + throw CryptoError.invalidKeyLength + } + + let sodium = Sodium() + + // Generate nonce automatically and return both ciphertext and nonce + guard let result: (authenticatedCipherText: [UInt8], nonce: [UInt8]) = sodium.aead.xchacha20poly1305ietf.encrypt( + message: data.bytes, + secretKey: key.bytes + ) else { + throw CryptoError.encryptionFailed + } + + return (Data(result.authenticatedCipherText), Data(result.nonce)) + } + + /// ChaCha20-Poly1305 decryption - standard libsodium AEAD + public static func decryptChaCha20Poly1305(cipherText: Data, key: Data, nonce: Data) throws -> Data { + guard key.count == 32 else { + throw CryptoError.invalidKeyLength + } + + let sodium = Sodium() + + guard let plainText = sodium.aead.xchacha20poly1305ietf.decrypt( + authenticatedCipherText: cipherText.bytes, + secretKey: key.bytes, + nonce: nonce.bytes + ) else { + throw CryptoError.decryptionFailed + } + + return Data(plainText) + } + + // MARK: - Stream Decryption (XChaCha20-Poly1305) + + /// Stream decryption using XChaCha20Poly1305 secretstream + /// This is the main method used for file content and metadata decryption in Ente + public static func decryptSecretStream(encryptedData: Data, key: Data, header: Data) throws -> Data { + guard key.count == 32 else { + throw CryptoError.invalidKeyLength + } + guard header.count == 24 else { + throw CryptoError.invalidParameters + } + + let sodium = Sodium() + let overhead = 17 // crypto_secretstream_xchacha20poly1305_ABYTES (libsodium per-chunk overhead) + let plaintextChunkSize = 4 * 1024 * 1024 // Matches Dart encryptionChunkSize + let cipherChunkSize = plaintextChunkSize + overhead + + guard let pull = sodium.secretStream.xchacha20poly1305.initPull( + secretKey: key.bytes, + header: header.bytes + ) else { + throw CryptoError.singleShotDecryptionFailed + } + + var offset = 0 + var chunkIndex = 0 + var output = Data() + let total = encryptedData.count + + while offset < total { + let remaining = total - offset + let take = remaining <= cipherChunkSize ? remaining : cipherChunkSize + let range = offset..<(offset + take) + let ct = encryptedData.subdata(in: range) + guard let (msg, tag) = pull.pull(cipherText: ct.bytes) else { + throw CryptoError.decryptionFailed + } + output.append(Data(msg)) + offset += take + chunkIndex += 1 + if tag == .FINAL { + if offset != total { + throw CryptoError.decryptionFailed + } + return output + } + } + throw CryptoError.decryptionFailed + } + + // MARK: - Cast Operations + + /// Generates X25519 keypair for cast pairing (using Swift CryptoKit for compatibility) + /// Returns base64 encoded keys for network transmission + public static func generateCastKeyPair() -> (publicKey: String, privateKey: String) { + let privateKey = Curve25519.KeyAgreement.PrivateKey() + let publicKey = privateKey.publicKey + + let publicKeyB64 = publicKey.rawRepresentation.base64EncodedString() + let privateKeyB64 = privateKey.rawRepresentation.base64EncodedString() + + return (publicKey: publicKeyB64, privateKey: privateKeyB64) + } + + /// Decrypts cast payload using sealed box (anonymous encryption) + /// Used to decrypt collection info sent from mobile client + public static func decryptCastPayload( + encryptedPayload: String, + recipientPublicKey: String, + recipientPrivateKey: String + ) throws -> Data { + guard let cipherText = Data(base64Encoded: encryptedPayload), + let publicKey = Data(base64Encoded: recipientPublicKey), + let privateKey = Data(base64Encoded: recipientPrivateKey) else { + throw CryptoError.invalidParameters + } + + return try sealedBoxOpen(cipherText, publicKey: publicKey, secretKey: privateKey) + } + + // MARK: - Hash Operations + + /// Computes BLAKE2b hash using libsodium genericHash (64-byte output) + /// This matches the hash format used across all Ente platforms + public static func computeBlake2bHash(_ data: Data) throws -> String { + let sodium = Sodium() + + guard let hashBytes = sodium.genericHash.hash(message: data.bytes, outputLength: 64) else { + throw CryptoError.derivationFailed + } + + return hashBytes.map { String(format: "%02x", $0) }.joined() + } + + /// Verifies file content hash with dual format support + /// Server stores hashes as base64, we compute as hex - handles both formats + public static func verifyFileHash(data: Data, expectedHash: String?) -> Bool { + guard let expectedHash = expectedHash, !expectedHash.isEmpty else { + // No hash available - allow for backwards compatibility + return true + } + + guard let computedHash = try? computeBlake2bHash(data) else { + return false + } + + // Try direct hex comparison first + if computedHash.lowercased() == expectedHash.lowercased() { + return true + } + + // Try base64 to hex conversion + if let base64DecodedHash = Data(base64Encoded: expectedHash) { + let expectedHashAsHex = base64DecodedHash.map { String(format: "%02x", $0) }.joined() + if computedHash.lowercased() == expectedHashAsHex.lowercased() { + return true + } + } + + return false + } +} + +// MARK: - Data Extension for Bytes + +extension Data { + var bytes: [UInt8] { + return Array(self) + } +} diff --git a/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/SRPClient.swift b/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/SRPClient.swift new file mode 100644 index 0000000000..c0c633f6da --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/Sources/EnteCrypto/SRPClient.swift @@ -0,0 +1,157 @@ +import Foundation +import EnteCore +import Sodium +import Crypto +import Logging +import BigInt + +// MARK: - SRP Constants + +public struct SRPConstants { + public static let groupSize = 4096 + public static let hashAlgorithm = "SHA-256" + public static let generator = "05" + + // RFC 5054 4096-bit safe prime + public static let prime = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199FFFFFFFFFFFFFFFF" +} + +// MARK: - SRP Client + +public class SRPClient { + private let logger = Logger(label: "SRPClient") + private let sodium = Sodium() + + private let identity: Data + private let password: Data + private let salt: Data + private let privateKey: BigInt + private var serverPublicKey: Data? + private var sharedSecret: Data? + private var clientPublicKey: Data? + + // RFC 5054 parameters + private let generator: BigInt + private let prime: BigInt + + public init(identity: Data, password: Data, salt: Data) throws { + self.identity = identity + self.password = password + self.salt = salt + + self.prime = BigInt(SRPConstants.prime, radix: 16)! + self.generator = BigInt(SRPConstants.generator, radix: 16)! + guard let randomBytes = sodium.randomBytes.buf(length: 32) else { + throw CryptoError.derivationFailed + } + self.privateKey = BigInt(Data(randomBytes)) + + logger.debug("SRP client initialized") + } + + // MARK: - Authentication Flow + + /// Generate client public key A = g^a mod N + public func generateClientCredentials() -> Data { + let A = generator.power(privateKey, modulus: prime) + let result = A.srpSerialize() + self.clientPublicKey = result + + logger.debug("Generated client public key (\(result.count) bytes)") + return result + } + + /// Process server public key and compute shared secret + public func processServerChallenge(serverPublicKey: Data) throws { + self.serverPublicKey = serverPublicKey + + guard let clientA = clientPublicKey else { + throw CryptoError.invalidParameters + } + + logger.debug("Processing server challenge (\(serverPublicKey.count) bytes)") + + let B = BigInt(serverPublicKey) + + let uData = SHA256.hash(data: clientA + serverPublicKey) + let u = BigInt(Data(uData)) + + let innerHash = SHA256.hash(data: identity + ":".data(using: .utf8)! + password) + let xData = SHA256.hash(data: salt + Data(innerHash)) + let x = BigInt(Data(xData)) + + let kData = SHA256.hash(data: prime.srpSerialize() + generator.srpSerialize()) + let k = BigInt(Data(kData)) + + let gx = generator.power(x, modulus: prime) + let kgx = (k * gx) % prime + let base = (B - kgx + prime) % prime + let exponent = (privateKey + u * x) + let S = base.power(exponent, modulus: prime) + + self.sharedSecret = S.srpSerialize() + logger.debug("Computed shared secret (\(sharedSecret!.count) bytes)") + } + + /// Generate client evidence M1 + public func generateClientEvidence() throws -> Data { + guard let clientA = clientPublicKey, + let serverB = serverPublicKey, + let S = sharedSecret else { + throw CryptoError.invalidParameters + } + + let m1 = Data(SHA256.hash(data: clientA + serverB + S)) + logger.debug("Generated client evidence (\(m1.count) bytes)") + return m1 + } + + /// Verify server evidence M2 + public func verifyServerEvidence(_ serverEvidence: Data) throws -> Bool { + guard let clientA = clientPublicKey, + let S = sharedSecret else { + throw CryptoError.invalidParameters + } + + let clientM1 = try generateClientEvidence() + let expectedM2Data = SHA256.hash(data: clientA + clientM1 + S) + let expectedM2 = Data(expectedM2Data) + + let isValid = expectedM2 == serverEvidence + logger.debug("Server evidence verification: \(isValid)") + return isValid + } + + /// Get session key from shared secret + public func getSessionKey() throws -> Data { + guard let S = sharedSecret else { + throw CryptoError.invalidParameters + } + + let sessionKeyData = SHA256.hash(data: S) + return Data(sessionKeyData) + } +} + +// MARK: - BigInt Extensions + +extension BigInt { + /// Create BigInt from byte data + init(_ data: Data) { + self.init(sign: .plus, magnitude: BigUInt(data)) + } + + /// Serialize to 512-byte padded format + func srpSerialize() -> Data { + let magnitude = self.magnitude + var data = magnitude.serialize() + + let targetLength = 512 + if data.count < targetLength { + let padding = Data(count: targetLength - data.count) + data = padding + data + } + + return data + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteCrypto/Tests/EnteCryptoTests/EnteCryptoTests.swift b/mobile/native/ios/Packages/EnteCrypto/Tests/EnteCryptoTests/EnteCryptoTests.swift new file mode 100644 index 0000000000..b761428241 --- /dev/null +++ b/mobile/native/ios/Packages/EnteCrypto/Tests/EnteCryptoTests/EnteCryptoTests.swift @@ -0,0 +1,160 @@ +import XCTest +@testable import EnteCrypto +@testable import EnteCore +@testable import EnteNetwork +import Foundation + +final class EnteCryptoTests: XCTestCase { + + // MARK: - Key Derivation Tests + + func testArgonKeyDerivation() throws { + let password = "testpassword123" + let salt = Data(count: 16).base64EncodedString() + let memLimit = 64 * 1024 * 1024 + let opsLimit = 3 + + let derivedKey = try EnteCrypto.deriveArgonKey( + password: password, + salt: salt, + memLimit: memLimit, + opsLimit: opsLimit + ) + + XCTAssertEqual(derivedKey.count, 32) + XCTAssertFalse(derivedKey.allSatisfy { $0 == 0 }) + } + + func testLoginKeyDerivation() throws { + let keyEncKey = Data(repeating: 1, count: 32) + let loginKey = try EnteCrypto.deriveLoginKey(keyEncKey: keyEncKey) + + XCTAssertEqual(loginKey.count, 16) + XCTAssertFalse(loginKey.allSatisfy { $0 == 0 }) + } + + // MARK: - Encryption Tests + + func testSecretBoxOperations() throws { + let message = "Hello, World!".data(using: .utf8)! + let key = Data(repeating: 1, count: 32) + let nonce = Data(repeating: 2, count: 24) + + XCTAssertThrowsError(try EnteCrypto.secretBoxOpen(message, nonce: nonce, key: key)) { error in + XCTAssertTrue(error is CryptoError) + } + } + + func testChaCha20Poly1305Operations() throws { + let message = "Hello, World!".data(using: .utf8)! + let key = Data(repeating: 1, count: 32) + + let (cipherText, nonce) = try EnteCrypto.encryptChaCha20Poly1305(data: message, key: key) + + XCTAssertFalse(cipherText.isEmpty) + XCTAssertEqual(nonce.count, 24) + XCTAssertNotEqual(cipherText, message) + + let decrypted = try EnteCrypto.decryptChaCha20Poly1305(cipherText: cipherText, key: key, nonce: nonce) + XCTAssertEqual(decrypted, message) + } + + // MARK: - SRP Tests + + func testSRPConstants() { + XCTAssertEqual(SRPConstants.groupSize, 4096) + XCTAssertEqual(SRPConstants.hashAlgorithm, "SHA-256") + XCTAssertEqual(SRPConstants.generator, "05") + XCTAssertFalse(SRPConstants.prime.isEmpty) + } + + func testSRPClientInitialization() throws { + let identity = "user@example.com".data(using: .utf8)! + let password = "password123".data(using: .utf8)! + let salt = Data(repeating: 3, count: 16) + + let srpClient = try SRPClient(identity: identity, password: password, salt: salt) + let clientPublicKey = srpClient.generateClientCredentials() + + XCTAssertFalse(clientPublicKey.isEmpty) + } + + // MARK: - Integration Tests + + func testLoginFlowIntegration() throws { + let email = "test@example.com" + let password = "testpassword" + let salt = Data(count: 16).base64EncodedString() + let memLimit = 64 * 1024 * 1024 + let opsLimit = 3 + + let keyEncKey = try EnteCrypto.deriveArgonKey( + password: password, + salt: salt, + memLimit: memLimit, + opsLimit: opsLimit + ) + XCTAssertEqual(keyEncKey.count, 32) + + let loginKey = try EnteCrypto.deriveLoginKey(keyEncKey: keyEncKey) + XCTAssertEqual(loginKey.count, 16) + + let identity = email.data(using: .utf8)! + let srpSalt = Data(repeating: 4, count: 16) + let srpClient = try SRPClient(identity: identity, password: loginKey, salt: srpSalt) + + let clientPublicKey = srpClient.generateClientCredentials() + XCTAssertFalse(clientPublicKey.isEmpty) + } + + // func testRealServerLoginFlow() async throws { + // let email = "<>" + // let password = "<>" + // let baseURL = URL(string: "http://localhost:8080")! + + // let config = NetworkConfiguration.selfHosted(baseURL: baseURL) + // let httpClient = HTTPClient() + // let apiClient = APIClient( + // configuration: config, + // app: .cast, + // authTokenProvider: nil, + // httpClient: httpClient + // ) + // let authGateway = AuthenticationGateway(client: apiClient) + + // do { + // let srpAttributes = try await authGateway.getSRPAttributes(email: email) + + // let keyEncKey = try EnteCrypto.deriveArgonKey( + // password: password, + // salt: srpAttributes.kekSalt, + // memLimit: srpAttributes.memLimit, + // opsLimit: srpAttributes.opsLimit + // ) + // let loginKey = try EnteCrypto.deriveLoginKey(keyEncKey: keyEncKey) + + // let identity = srpAttributes.srpUserID.data(using: String.Encoding.utf8)! + // let srpSalt = Data(base64Encoded: srpAttributes.srpSalt)! + // let srpClient = try SRPClient(identity: identity, password: loginKey, salt: srpSalt) + + // let clientPublicKey = srpClient.generateClientCredentials() + // let srpSession = try await authGateway.createSRPSession( + // srpUserID: srpAttributes.srpUserID, + // clientPub: clientPublicKey.base64EncodedString() + // ) + + // let serverPublicKey = Data(base64Encoded: srpSession.srpB)! + // try srpClient.processServerChallenge(serverPublicKey: serverPublicKey) + + // let clientEvidence = try srpClient.generateClientEvidence() + // _ = try await authGateway.verifySRPSession( + // srpUserID: srpAttributes.srpUserID, + // sessionID: srpSession.sessionID, + // clientM1: clientEvidence.base64EncodedString() + // ) + + // } catch { + // XCTFail("Login flow failed with error: \(error)") + // } + // } +} diff --git a/mobile/native/ios/Packages/EnteNetwork/.gitignore b/mobile/native/ios/Packages/EnteNetwork/.gitignore new file mode 100644 index 0000000000..0023a53406 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mobile/native/ios/Packages/EnteNetwork/Package.resolved b/mobile/native/ios/Packages/EnteNetwork/Package.resolved new file mode 100644 index 0000000000..1e52e1ff8f --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + } + ], + "version" : 2 +} diff --git a/mobile/native/ios/Packages/EnteNetwork/Package.swift b/mobile/native/ios/Packages/EnteNetwork/Package.swift new file mode 100644 index 0000000000..aa57f8386f --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EnteNetwork", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "EnteNetwork", + targets: ["EnteNetwork"] + ), + ], + dependencies: [ + .package(path: "../EnteCore"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "EnteNetwork", + dependencies: [ + "EnteCore", + .product(name: "Logging", package: "swift-log"), + ] + ), + .testTarget( + name: "EnteNetworkTests", + dependencies: ["EnteNetwork"] + ), + ] +) diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Configuration/NetworkConfiguration.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Configuration/NetworkConfiguration.swift new file mode 100644 index 0000000000..afef1a31d3 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Configuration/NetworkConfiguration.swift @@ -0,0 +1,114 @@ +import Foundation +import EnteCore + +// MARK: - Network Configuration + +public struct NetworkConfiguration { + public let apiEndpoint: URL + public let accountsEndpoint: URL? + public let castEndpoint: URL? + public let publicAlbumsEndpoint: URL? + public let familyEndpoint: URL? + + public init( + apiEndpoint: URL, + accountsEndpoint: URL? = nil, + castEndpoint: URL? = nil, + publicAlbumsEndpoint: URL? = nil, + familyEndpoint: URL? = nil + ) { + self.apiEndpoint = apiEndpoint + self.accountsEndpoint = accountsEndpoint + self.castEndpoint = castEndpoint + self.publicAlbumsEndpoint = publicAlbumsEndpoint + self.familyEndpoint = familyEndpoint + } + + // Convenience initializer for self-hosted instances + public static func selfHosted(baseURL: URL) -> NetworkConfiguration { + return NetworkConfiguration( + apiEndpoint: baseURL, + accountsEndpoint: baseURL.appendingPathComponent("accounts"), + castEndpoint: baseURL.appendingPathComponent("cast"), + publicAlbumsEndpoint: baseURL.appendingPathComponent("public-albums"), + familyEndpoint: baseURL.appendingPathComponent("family") + ) + } + + // Default Ente.io configuration + public static let `default` = NetworkConfiguration( + apiEndpoint: URL(string: "https://api.ente.io")!, + accountsEndpoint: URL(string: "https://accounts.ente.io"), + castEndpoint: URL(string: "https://api.ente.io"), // Fix: Use same endpoint as web app + publicAlbumsEndpoint: URL(string: "https://albums.ente.io"), + familyEndpoint: URL(string: "https://family.ente.io") + ) +} + +// MARK: - Environment Configuration + +public enum EnteEnvironment { + case production + case staging + case development + case selfHosted(URL) + + public var networkConfiguration: NetworkConfiguration { + switch self { + case .production: + return .default + case .staging: + return NetworkConfiguration( + apiEndpoint: URL(string: "https://api.staging.ente.io")!, + accountsEndpoint: URL(string: "https://accounts.staging.ente.io"), + castEndpoint: URL(string: "https://cast.staging.ente.io") + ) + case .development: + return NetworkConfiguration( + apiEndpoint: URL(string: "http://localhost:8080")! + ) + case .selfHosted(let baseURL): + return NetworkConfiguration.selfHosted(baseURL: baseURL) + } + } +} + +// MARK: - API Domain + +public enum APIDomain { + case api // Main API server + case accounts // Account management + case cast // TV/Cast operations + case publicAlbums // Public album sharing + case family // Family plan management +} + +// MARK: - Endpoint Resolver + +public class EndpointResolver { + private let configuration: NetworkConfiguration + + public init(configuration: NetworkConfiguration) { + self.configuration = configuration + } + + public func resolveURL(for endpoint: APIEndpoint) -> URL { + let baseURL = resolveBaseURL(for: endpoint.domain) + return baseURL.appendingPathComponent(endpoint.path) + } + + private func resolveBaseURL(for domain: APIDomain) -> URL { + switch domain { + case .api: + return configuration.apiEndpoint + case .accounts: + return configuration.accountsEndpoint ?? configuration.apiEndpoint + case .cast: + return configuration.castEndpoint ?? configuration.apiEndpoint + case .publicAlbums: + return configuration.publicAlbumsEndpoint ?? configuration.apiEndpoint + case .family: + return configuration.familyEndpoint ?? configuration.apiEndpoint + } + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIClient.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIClient.swift new file mode 100644 index 0000000000..e6d74e10d3 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIClient.swift @@ -0,0 +1,98 @@ +import Foundation +import EnteCore +import Logging + +// MARK: - API Client + +public class APIClient { + private let httpClient: HTTPClient + private let endpointResolver: EndpointResolver + private let headersManager: RequestHeadersManager + private let authTokenProvider: AuthTokenProvider? + private let app: EnteApp + private let logger = Logger(label: "APIClient") + + public init( + configuration: NetworkConfiguration, + app: EnteApp, + authTokenProvider: AuthTokenProvider? = nil, + httpClient: HTTPClient = HTTPClient() + ) { + self.httpClient = httpClient + self.endpointResolver = EndpointResolver(configuration: configuration) + self.headersManager = RequestHeadersManager() + self.authTokenProvider = authTokenProvider + self.app = app + } + + public func request(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T { + let url = endpointResolver.resolveURL(for: endpoint) + let headers = await buildHeaders(for: endpoint) + + logger.debug("Making request to \(url)") + + return try await httpClient.request( + url: url, + method: endpoint.method, + parameters: endpoint.parameters, + headers: headers, + responseType: responseType + ) + } + + public func request(_ endpoint: APIEndpoint) async throws -> EmptyResponse { + return try await request(endpoint, responseType: EmptyResponse.self) + } + + public func upload(_ endpoint: APIEndpoint, data: Data) async throws -> Data { + let url = endpointResolver.resolveURL(for: endpoint) + let headers = await buildHeaders(for: endpoint) + + return try await httpClient.upload(url: url, data: data, headers: headers) + } + + public func download(_ endpoint: APIEndpoint) async throws -> Data { + let url = endpointResolver.resolveURL(for: endpoint) + let headers = await buildHeaders(for: endpoint) + + return try await httpClient.download(url: url, headers: headers) + } + + private func buildHeaders(for endpoint: APIEndpoint) async -> [String: String] { + // Start with endpoint-specific headers + var headers = endpoint.headers ?? [:] + + // Add common headers (mirrors mobile network.dart pattern) + let commonHeaders = await headersManager.buildHeaders( + app: app, + authTokenProvider: authTokenProvider + ) + + // Merge headers (endpoint headers take precedence) + for (key, value) in commonHeaders { + if headers[key] == nil { + headers[key] = value + } + } + + return headers + } +} + +// MARK: - Simple Auth Token Provider + +public class SimpleAuthTokenProvider: AuthTokenProvider { + private var token: String? + + public init(token: String? = nil) { + self.token = token + } + + public func getAuthToken() async throws -> String? { + return token + } + + public func setAuthToken(_ token: String?) { + self.token = token + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIEndpoint.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIEndpoint.swift new file mode 100644 index 0000000000..483fdadf18 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/APIEndpoint.swift @@ -0,0 +1,196 @@ +import Foundation +import EnteCore + +// MARK: - API Endpoint Protocol + +public protocol APIEndpoint { + var domain: APIDomain { get } + var path: String { get } + var method: HTTPMethod { get } + var parameters: [String: Any]? { get } + var headers: [String: String]? { get } +} + +// MARK: - Authentication Endpoints + +public enum AuthEndpoint: APIEndpoint { + case getSRPAttributes(email: String) + case createSRPSession(srpUserID: String, clientPub: String) + case verifySRPSession(srpUserID: String, sessionID: String, clientM1: String) + case sendLoginOTP(email: String, purpose: String) + case verifyEmail(email: String, otp: String) + case verifyTOTP(sessionID: String, otp: String) + case getTokenForPasskeySession(sessionID: String) + + public var domain: APIDomain { + return .api + } + + public var path: String { + switch self { + case .getSRPAttributes: + return "/users/srp/attributes" + case .createSRPSession: + return "/users/srp/create-session" + case .verifySRPSession: + return "/users/srp/verify-session" + case .sendLoginOTP: + return "/users/ott" + case .verifyEmail: + return "/users/verify-email" + case .verifyTOTP: + return "/users/two-factor/verify" + case .getTokenForPasskeySession: + return "/users/two-factor/passkeys/get-token" + } + } + + public var method: HTTPMethod { + switch self { + case .getSRPAttributes, .getTokenForPasskeySession: + return .GET + case .createSRPSession, .verifySRPSession, .sendLoginOTP, .verifyEmail, .verifyTOTP: + return .POST + } + } + + public var parameters: [String: Any]? { + switch self { + case .getSRPAttributes(let email): + return ["email": email] + case .createSRPSession(let srpUserID, let clientPub): + return ["srpUserID": srpUserID, "srpA": clientPub] + case .verifySRPSession(let srpUserID, let sessionID, let clientM1): + return ["srpUserID": srpUserID, "sessionID": sessionID, "srpM1": clientM1] + case .sendLoginOTP(let email, let purpose): + return ["email": email, "purpose": purpose] + case .verifyEmail(let email, let otp): + return ["email": email, "ott": otp] + case .verifyTOTP(let sessionID, let otp): + return ["sessionID": sessionID, "code": otp] + case .getTokenForPasskeySession(let sessionID): + return ["sessionID": sessionID] + } + } + + public var headers: [String: String]? { return nil } +} + +// MARK: - Cast Endpoints (TV-specific) + +public enum CastEndpoint: APIEndpoint { + case registerDevice + case getDeviceInfo(deviceCode: String) + case getCastData(deviceCode: String) + case insertCastData([String: Any]) + case revokeAllTokens + case getThumbnail(fileID: FileID) + case getFile(fileID: FileID) + case getDiff(sinceTime: Int64) + case getCollection + + public var domain: APIDomain { + return .cast // All cast operations use cast domain + } + + public var path: String { + switch self { + case .registerDevice: + return "/cast/device-info" + case .getDeviceInfo(let code): + return "/cast/device-info/\(code)" + case .getCastData(let code): + return "/cast/cast-data/\(code)" + case .insertCastData: + return "/cast/cast-data" + case .revokeAllTokens: + return "/cast/revoke-all-tokens" + case .getThumbnail(let fileID): + return "/files/preview/\(fileID.rawValue)" + case .getFile(let fileID): + return "/files/download/\(fileID.rawValue)" + case .getDiff: + return "/diff" + case .getCollection: + return "/info" + } + } + + public var method: HTTPMethod { + switch self { + case .registerDevice, .insertCastData: + return .POST + case .revokeAllTokens: + return .DELETE + case .getDeviceInfo, .getCastData, .getThumbnail, .getFile, .getDiff, .getCollection: + return .GET + } + } + + public var parameters: [String: Any]? { + switch self { + case .getDiff(let sinceTime): + return ["sinceTime": sinceTime] + case .insertCastData(let data): + return data + default: + return nil + } + } + + public var headers: [String: String]? { return nil } +} + +// MARK: - User Endpoints + +public enum UserEndpoint: APIEndpoint { + case getUserDetails(fetchMemoryCount: Bool) + case logout + case getActiveSessions + case terminateSession(sessionID: String) + case deleteUser(challenge: String) + case getDeleteChallenge + + public var domain: APIDomain { return .api } + + public var path: String { + switch self { + case .getUserDetails: + return "/users/details/v2" + case .logout: + return "/users/logout" + case .getActiveSessions: + return "/users/sessions" + case .terminateSession: + return "/users/session" + case .deleteUser: + return "/users/delete" + case .getDeleteChallenge: + return "/users/delete-challenge" + } + } + + public var method: HTTPMethod { + switch self { + case .getUserDetails, .getActiveSessions, .getDeleteChallenge: + return .GET + case .logout: + return .POST + case .terminateSession, .deleteUser: + return .DELETE + } + } + + public var parameters: [String: Any]? { + switch self { + case .getUserDetails(let fetchMemoryCount): + return ["memoryCount": fetchMemoryCount] + case .deleteUser(let challenge): + return ["challenge": challenge] + default: + return nil + } + } + + public var headers: [String: String]? { return nil } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/HTTPClient.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/HTTPClient.swift new file mode 100644 index 0000000000..806b4e8de8 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Core/HTTPClient.swift @@ -0,0 +1,225 @@ +import Foundation +import EnteCore +import Logging + +// MARK: - HTTP Method + +public enum HTTPMethod: String { + case GET = "GET" + case POST = "POST" + case PUT = "PUT" + case DELETE = "DELETE" + case PATCH = "PATCH" +} + +// MARK: - Auth Token Provider + +public protocol AuthTokenProvider { + func getAuthToken() async throws -> String? +} + +// MARK: - HTTP Headers Builder + +public struct HTTPHeaders { + private var headers: [String: String] = [:] + + public init() {} + + public mutating func add(name: String, value: String) { + headers[name] = value + } + + public func build() -> [String: String] { + return headers + } +} + +// MARK: - Request Headers Manager + +public class RequestHeadersManager { + private let logger = Logger(label: "RequestHeadersManager") + + public init() {} + + /// Builds headers based on mobile packages pattern - mirrors network.dart + public func buildHeaders( + app: EnteApp, + authTokenProvider: AuthTokenProvider? = nil + ) async -> [String: String] { + var headers = HTTPHeaders() + + // Platform-specific User-Agent (mirrors mobile pattern) + let platform = EntePlatform.current + headers.add(name: "User-Agent", value: platform.userAgent) + + // Client version - using bundle version + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + headers.add(name: "X-Client-Version", value: version) + } + + // Client package - mirrors mobile packageName pattern + headers.add(name: "X-Client-Package", value: app.packageIdentifier) + + // Request ID for tracing (mirrors mobile x-request-id) + headers.add(name: "x-request-id", value: UUID().uuidString) + + // Auth token if available (mirrors mobile X-Auth-Token) + if let token = try? await authTokenProvider?.getAuthToken() { + headers.add(name: "X-Auth-Token", value: token) + } + + return headers.build() + } +} + +// MARK: - HTTP Client + +public class HTTPClient { + private let session: URLSession + private let headersManager: RequestHeadersManager + private let logger = Logger(label: "HTTPClient") + + public init( + session: URLSession = .shared, + headersManager: RequestHeadersManager = RequestHeadersManager() + ) { + self.session = session + self.headersManager = headersManager + } + + public func request( + url: URL, + method: HTTPMethod, + parameters: [String: Any]? = nil, + headers: [String: String]? = nil, + responseType: T.Type + ) async throws -> T { + var finalURL = url + + // For GET requests, add parameters as query parameters + if let parameters = parameters, method == .GET { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + var queryItems = components.queryItems ?? [] + + for (key, value) in parameters { + // URLQueryItem will handle proper encoding automatically + queryItems.append(URLQueryItem(name: key, value: "\(value)")) + } + + components.queryItems = queryItems + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + finalURL = components.url! + print("๐ŸŒ Final URL with encoded parameters: \(finalURL)") + } + + var request = URLRequest(url: finalURL) + request.httpMethod = method.rawValue + + // Add headers + if let headers = headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + // Add JSON body for POST/PUT requests + if let parameters = parameters, + method == .POST || method == .PUT || method == .PATCH { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: parameters) + } + + logger.debug("Making \(method.rawValue) request to \(finalURL)") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw EnteError.networkError("Invalid response type") + } + + logger.debug("Response status: \(httpResponse.statusCode)") + + // Handle error responses + if httpResponse.statusCode >= 400 { + if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + throw EnteError.serverError(httpResponse.statusCode, errorResponse.message) + } else { + throw EnteError.serverError(httpResponse.statusCode, nil) + } + } + + // Handle empty responses + if T.self == EmptyResponse.self { + return EmptyResponse() as! T + } + + // Decode JSON response + do { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch { + logger.error("Failed to decode response: \(error)") + throw EnteError.invalidResponse + } + } + + public func upload( + url: URL, + data: Data, + headers: [String: String]? = nil + ) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = data + + // Add headers + if let headers = headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + logger.debug("Uploading data to \(url)") + + let (responseData, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw EnteError.networkError("Invalid response type") + } + + if httpResponse.statusCode >= 400 { + throw EnteError.serverError(httpResponse.statusCode, nil) + } + + return responseData + } + + public func download( + url: URL, + headers: [String: String]? = nil + ) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "GET" + + // Add headers + if let headers = headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + logger.debug("Downloading from \(url)") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw EnteError.networkError("Invalid response type") + } + + if httpResponse.statusCode >= 400 { + throw EnteError.serverError(httpResponse.statusCode, nil) + } + + return data + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetwork.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetwork.swift new file mode 100644 index 0000000000..93e8aaf160 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetwork.swift @@ -0,0 +1,7 @@ +// EnteNetwork - Native iOS/tvOS networking layer for Ente apps +// Provides domain-specific gateways with custom endpoint support + +@_exported import EnteCore + +// Re-export key types for convenience +public typealias NetworkFactory = EnteNetworkFactory diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetworkFactory.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetworkFactory.swift new file mode 100644 index 0000000000..6917de9385 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/EnteNetworkFactory.swift @@ -0,0 +1,85 @@ +import Foundation +import EnteCore +import Logging + +// MARK: - Ente Network Factory + +public class EnteNetworkFactory { + private let apiClient: APIClient + public let configuration: NetworkConfiguration + private let logger = Logger(label: "EnteNetworkFactory") + + public init( + configuration: NetworkConfiguration = .default, + app: EnteApp, + authTokenProvider: AuthTokenProvider? = nil + ) { + self.configuration = configuration + self.apiClient = APIClient( + configuration: configuration, + app: app, + authTokenProvider: authTokenProvider + ) + + logger.info("Initialized network factory for app: \(app.displayName)") + logger.info("API endpoint: \(configuration.apiEndpoint)") + if let castEndpoint = configuration.castEndpoint { + logger.info("Cast endpoint: \(castEndpoint)") + } + } + + // MARK: - Convenience Initializers + + public convenience init( + environment: EnteEnvironment, + app: EnteApp, + authTokenProvider: AuthTokenProvider? = nil + ) { + self.init( + configuration: environment.networkConfiguration, + app: app, + authTokenProvider: authTokenProvider + ) + } + + public convenience init( + customEndpoint: URL, + app: EnteApp, + authTokenProvider: AuthTokenProvider? = nil + ) { + let config = NetworkConfiguration.selfHosted(baseURL: customEndpoint) + self.init( + configuration: config, + app: app, + authTokenProvider: authTokenProvider + ) + } + + // MARK: - Gateway Access + + /// Core authentication gateway for sign-in flows + public lazy var authentication = AuthenticationGateway(client: apiClient) + + /// TV/Cast specific gateway - uses dedicated cast endpoints + public lazy var cast = CastGateway(client: apiClient) + + // TODO: Add other gateways as needed + // public lazy var users = UserGateway(client: apiClient) + // public lazy var files = FilesGateway(client: apiClient) + // public lazy var collections = CollectionsGateway(client: apiClient) + // public lazy var billing = BillingGateway(client: apiClient) + // public lazy var trash = TrashGateway(client: apiClient) + // public lazy var storageBonus = StorageBonusGateway(client: apiClient) + // public lazy var family = FamilyGateway(client: apiClient) + // public lazy var remoteStore = RemoteStoreGateway(client: apiClient) + // public lazy var push = PushGateway(client: apiClient) + // public lazy var keyExchange = KeyExchangeGateway(client: apiClient) + + // MARK: - Configuration Updates + + public func updateConfiguration(_ newConfiguration: NetworkConfiguration) { + // This would require updating the APIClient's configuration + // For now, we recommend creating a new factory instance + logger.warning("Configuration updates require creating a new NetworkFactory instance") + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/AuthenticationGateway.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/AuthenticationGateway.swift new file mode 100644 index 0000000000..b6a982ae67 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/AuthenticationGateway.swift @@ -0,0 +1,174 @@ +import Foundation +import EnteCore +import Logging + +// MARK: - Authentication Models + +public struct SRPAttributes: Codable { + public let srpUserID: String + public let srpSalt: String + public let kekSalt: String + public let memLimit: Int + public let opsLimit: Int + public let isEmailMFAEnabled: Bool + + public init(srpUserID: String, srpSalt: String, kekSalt: String, memLimit: Int, opsLimit: Int, isEmailMFAEnabled: Bool) { + self.srpUserID = srpUserID + self.srpSalt = srpSalt + self.kekSalt = kekSalt + self.memLimit = memLimit + self.opsLimit = opsLimit + self.isEmailMFAEnabled = isEmailMFAEnabled + } +} + +public struct CreateSRPSessionResponse: Codable { + public let sessionID: String + public let srpB: String + + public init(sessionID: String, srpB: String) { + self.sessionID = sessionID + self.srpB = srpB + } +} + +public struct KeyAttributes: Codable { + public let kekSalt: String + public let encryptedKey: String + public let keyDecryptionNonce: String + public let publicKey: String + public let encryptedSecretKey: String + public let secretKeyDecryptionNonce: String + public let memLimit: Int + public let opsLimit: Int + + public init(kekSalt: String, encryptedKey: String, keyDecryptionNonce: String, publicKey: String, encryptedSecretKey: String, secretKeyDecryptionNonce: String, memLimit: Int, opsLimit: Int) { + self.kekSalt = kekSalt + self.encryptedKey = encryptedKey + self.keyDecryptionNonce = keyDecryptionNonce + self.publicKey = publicKey + self.encryptedSecretKey = encryptedSecretKey + self.secretKeyDecryptionNonce = secretKeyDecryptionNonce + self.memLimit = memLimit + self.opsLimit = opsLimit + } +} + +public struct AuthorizationResponse: Codable { + public let keyAttributes: KeyAttributes? + public let encryptedToken: String? + public let token: String? + public let twoFactorSessionID: String? + public let twoFactorSessionIDV2: String? + public let passkeySessionID: String? + public let accountsUrl: String? + public let id: UserID + + public init(keyAttributes: KeyAttributes?, encryptedToken: String?, token: String?, twoFactorSessionID: String?, twoFactorSessionIDV2: String?, passkeySessionID: String?, accountsUrl: String?, id: UserID) { + self.keyAttributes = keyAttributes + self.encryptedToken = encryptedToken + self.token = token + self.twoFactorSessionID = twoFactorSessionID + self.twoFactorSessionIDV2 = twoFactorSessionIDV2 + self.passkeySessionID = passkeySessionID + self.accountsUrl = accountsUrl + self.id = id + } + + public var effectiveTwoFactorSessionID: String? { + return twoFactorSessionIDV2 ?? twoFactorSessionID + } + + public var isMFARequired: Bool { + return effectiveTwoFactorSessionID?.isEmpty == false + } + + public var isPasskeyRequired: Bool { + return passkeySessionID?.isEmpty == false + } +} + +// MARK: - Authentication Gateway + +public class AuthenticationGateway { + private let client: APIClient + private let logger = Logger(label: "AuthenticationGateway") + + internal init(client: APIClient) { + self.client = client + } + + // MARK: - SRP Authentication + + public func getSRPAttributes(email: String) async throws -> SRPAttributes { + logger.info("Getting SRP attributes for email") + + struct Response: Codable { + let attributes: SRPAttributes + } + + let response = try await client.request( + AuthEndpoint.getSRPAttributes(email: email), + responseType: Response.self + ) + + return response.attributes + } + + public func createSRPSession(srpUserID: String, clientPub: String) async throws -> CreateSRPSessionResponse { + logger.info("Creating SRP session") + + return try await client.request( + AuthEndpoint.createSRPSession(srpUserID: srpUserID, clientPub: clientPub), + responseType: CreateSRPSessionResponse.self + ) + } + + public func verifySRPSession(srpUserID: String, sessionID: String, clientM1: String) async throws -> AuthorizationResponse { + logger.info("Verifying SRP session") + + return try await client.request( + AuthEndpoint.verifySRPSession(srpUserID: srpUserID, sessionID: sessionID, clientM1: clientM1), + responseType: AuthorizationResponse.self + ) + } + + // MARK: - Email OTP + + public func sendLoginOTP(email: String, purpose: String = "login") async throws { + logger.info("Sending login OTP") + + try await client.request(AuthEndpoint.sendLoginOTP(email: email, purpose: purpose)) + } + + public func verifyEmail(email: String, otp: String) async throws -> AuthorizationResponse { + logger.info("Verifying email with OTP") + + return try await client.request( + AuthEndpoint.verifyEmail(email: email, otp: otp), + responseType: AuthorizationResponse.self + ) + } + + // MARK: - Two-Factor Authentication + + public func verifyTOTP(sessionID: String, otp: String) async throws -> AuthorizationResponse { + logger.info("Verifying TOTP") + + return try await client.request( + AuthEndpoint.verifyTOTP(sessionID: sessionID, otp: otp), + responseType: AuthorizationResponse.self + ) + } + + // MARK: - Passkeys + + public func getTokenForPasskeySession(sessionID: String) async throws -> AuthorizationResponse { + logger.info("Getting token for passkey session") + + return try await client.request( + AuthEndpoint.getTokenForPasskeySession(sessionID: sessionID), + responseType: AuthorizationResponse.self + ) + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/CastGateway.swift b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/CastGateway.swift new file mode 100644 index 0000000000..b51ff493a0 --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Sources/EnteNetwork/Gateways/CastGateway.swift @@ -0,0 +1,155 @@ +import Foundation +import EnteCore +import Logging + +// MARK: - Cast Models + +public struct CastDevice: Codable { + public let code: String + public let deviceID: String? + public let publicKey: String? + + public init(code: String, deviceID: String?, publicKey: String?) { + self.code = code + self.deviceID = deviceID + self.publicKey = publicKey + } +} + +public struct CastData: Codable { + public let deviceCode: String + public let collectionID: CollectionID + public let castToken: String + + public init(deviceCode: String, collectionID: CollectionID, castToken: String) { + self.deviceCode = deviceCode + self.collectionID = collectionID + self.castToken = castToken + } + + public func toDictionary() -> [String: Any] { + return [ + "deviceCode": deviceCode, + "collectionID": collectionID.rawValue, + "castToken": castToken + ] + } +} + +public struct CastDiff: Codable { + public let diff: [CastFile] + public let hasMore: Bool + + public init(diff: [CastFile], hasMore: Bool) { + self.diff = diff + self.hasMore = hasMore + } +} + +public struct CastFile: Codable { + public let id: FileID + public let updationTime: Int64 + public let isDeleted: Bool + + public init(id: FileID, updationTime: Int64, isDeleted: Bool) { + self.id = id + self.updationTime = updationTime + self.isDeleted = isDeleted + } +} + +public struct CastCollection: Codable { + public let id: CollectionID + public let name: String + public let fileCount: Int + + public init(id: CollectionID, name: String, fileCount: Int) { + self.id = id + self.name = name + self.fileCount = fileCount + } +} + +// MARK: - Cast Gateway + +public class CastGateway { + private let client: APIClient + private let logger = Logger(label: "CastGateway") + + internal init(client: APIClient) { + self.client = client + } + + // MARK: - Cast Device Management + + public func registerDevice() async throws -> CastDevice { + logger.info("Registering cast device") + + return try await client.request( + CastEndpoint.registerDevice, + responseType: CastDevice.self + ) + } + + public func getDeviceInfo(deviceCode: String) async throws -> CastDevice { + logger.info("Getting device info for code: \(deviceCode)") + + return try await client.request( + CastEndpoint.getDeviceInfo(deviceCode: deviceCode), + responseType: CastDevice.self + ) + } + + public func getCastData(deviceCode: String) async throws -> CastData { + logger.info("Getting cast data for device: \(deviceCode)") + + return try await client.request( + CastEndpoint.getCastData(deviceCode: deviceCode), + responseType: CastData.self + ) + } + + public func insertCastData(_ data: CastData) async throws { + logger.info("Inserting cast data") + + try await client.request(CastEndpoint.insertCastData(data.toDictionary())) + } + + public func revokeAllTokens() async throws { + logger.info("Revoking all cast tokens") + + try await client.request(CastEndpoint.revokeAllTokens) + } + + // MARK: - Cast File Operations + + public func getThumbnail(fileID: FileID) async throws -> Data { + logger.info("Getting thumbnail for file: \(fileID)") + + return try await client.download(CastEndpoint.getThumbnail(fileID: fileID)) + } + + public func getFile(fileID: FileID) async throws -> Data { + logger.info("Getting file: \(fileID)") + + return try await client.download(CastEndpoint.getFile(fileID: fileID)) + } + + public func getDiff(sinceTime: Int64) async throws -> CastDiff { + logger.info("Getting cast diff since: \(sinceTime)") + + return try await client.request( + CastEndpoint.getDiff(sinceTime: sinceTime), + responseType: CastDiff.self + ) + } + + public func getCollection() async throws -> CastCollection { + logger.info("Getting cast collection info") + + return try await client.request( + CastEndpoint.getCollection, + responseType: CastCollection.self + ) + } +} \ No newline at end of file diff --git a/mobile/native/ios/Packages/EnteNetwork/Tests/EnteNetworkTests/EnteNetworkTests.swift b/mobile/native/ios/Packages/EnteNetwork/Tests/EnteNetworkTests/EnteNetworkTests.swift new file mode 100644 index 0000000000..bdcfe7f88a --- /dev/null +++ b/mobile/native/ios/Packages/EnteNetwork/Tests/EnteNetworkTests/EnteNetworkTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import EnteNetwork + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/mobile/native/ios/ios.xcworkspace/contents.xcworkspacedata b/mobile/native/ios/ios.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..91dce29fae --- /dev/null +++ b/mobile/native/ios/ios.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/native/ios/ios.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/native/ios/ios.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..01bf70b70a --- /dev/null +++ b/mobile/native/ios/ios.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,68 @@ +{ + "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34", + "version" : "3.15.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342", + "version" : "0.9.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } + } + ], + "version" : 2 +}