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
+}