From f4210abcd2801ebc33edc1cb75ca19ad4190e4b0 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sun, 25 Oct 2026 20:22:39 +0100 Subject: [PATCH 1/3] chore: Let Xcode sort the entries of the widget extension in the project file Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- NextcloudTalk.xcodeproj/project.pbxproj | 308 ++++++++++++------------ 1 file changed, 154 insertions(+), 154 deletions(-) diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index fc7ad01e1..3c76c50a1 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - AACE32068D5BE4716C35504B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2C7F47AC20289B9600081CC7 /* Localizable.strings */; }; 07F8FEC44AC7B36560C5EE1B /* TalkWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1F0ECBF52A68274400921E90 /* CDMarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1F0ECBF42A68274400921E90 /* CDMarkdownKit */; }; 1F0ECBF72A68277000921E90 /* CDMarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1F0ECBF62A68277000921E90 /* CDMarkdownKit */; }; @@ -159,16 +158,10 @@ 847EFC7236336B67A1A89358 /* libPods-BroadcastUploadExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A3D305FCD7BF7E727A62F35 /* libPods-BroadcastUploadExtension.a */; }; 8789AE73BFCAA413B43319C0 /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 684807120F4439797973DF73 /* libPods-ShareExtension.a */; }; 9993261EDAC77481FF4EF58A /* libPods-NextcloudTalk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F7C31E9D74F550EAF89931B /* libPods-NextcloudTalk.a */; }; + AACE32068D5BE4716C35504B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2C7F47AC20289B9600081CC7 /* Localizable.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - B38B10737B74861948D4B46A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 404DF6649BF5F70D4316B064; - remoteInfo = TalkWidgets; - }; 1F6D8C342B2E3756004376B8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; @@ -211,6 +204,13 @@ remoteGlobalIDString = 2CC0014E24A1F0E900A20167; remoteInfo = NotificationServiceExtension; }; + B38B10737B74861948D4B46A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 404DF6649BF5F70D4316B064; + remoteInfo = TalkWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -292,7 +292,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TalkWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 1F21A0622C77863500ED8C0C /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "nb-NO.lproj/InfoPlist.strings"; sourceTree = ""; }; 1F21A0632C77863500ED8C0C /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nb-NO"; path = "nb-NO.lproj/Localizable.strings"; sourceTree = ""; }; 1F21A0642C77863500ED8C0C /* nb-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "nb-NO"; path = "nb-NO.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -465,6 +464,7 @@ 95D756208A81284B975853EC /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = ""; }; 9A3D305FCD7BF7E727A62F35 /* libPods-BroadcastUploadExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BroadcastUploadExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 9B81BB7A4920C391CC2CACFD /* libPods-NotificationServiceExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NotificationServiceExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TalkWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A8F95DE6635ABC1E64CA8E4A /* Pods-BroadcastUploadExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BroadcastUploadExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-BroadcastUploadExtension/Pods-BroadcastUploadExtension.release.xcconfig"; sourceTree = ""; }; B7874918820589BF8FD69BED /* Pods-NextcloudTalkTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NextcloudTalkTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-NextcloudTalkTests/Pods-NextcloudTalkTests.release.xcconfig"; sourceTree = ""; }; D6DF51D976DC0F681FF83F7B /* Pods-NotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; @@ -472,20 +472,6 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 8B65C957AD2D5B226E3E451F /* Exceptions for "TalkWidgets" folder in "NextcloudTalk" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - ConversationsWidgetData.swift, - ); - target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; - }; - AF55F227961AC70B32D0CA6E /* Exceptions for "TalkWidgets" folder in "TalkWidgets" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 404DF6649BF5F70D4316B064 /* TalkWidgets */; - }; 1F2058712CEBE4E200AAA673 /* Exceptions for "NotificationServiceExtension" folder in "NotificationServiceExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -1000,18 +986,23 @@ ); target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; }; + 8B65C957AD2D5B226E3E451F /* Exceptions for "TalkWidgets" folder in "NextcloudTalk" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ConversationsWidgetData.swift, + ); + target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; + }; + AF55F227961AC70B32D0CA6E /* Exceptions for "TalkWidgets" folder in "TalkWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 404DF6649BF5F70D4316B064 /* TalkWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 276FE3D714359BBDDFCFD105 /* TalkWidgets */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 8B65C957AD2D5B226E3E451F /* Exceptions for "TalkWidgets" folder in "NextcloudTalk" target */, - AF55F227961AC70B32D0CA6E /* Exceptions for "TalkWidgets" folder in "TalkWidgets" target */, - ); - path = TalkWidgets; - sourceTree = ""; - }; 1F20586F2CEBE4E200AAA673 /* NotificationServiceExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -1206,16 +1197,18 @@ path = TalkIntents; sourceTree = ""; }; + 276FE3D714359BBDDFCFD105 /* TalkWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8B65C957AD2D5B226E3E451F /* Exceptions for "TalkWidgets" folder in "NextcloudTalk" target */, + AF55F227961AC70B32D0CA6E /* Exceptions for "TalkWidgets" folder in "TalkWidgets" target */, + ); + path = TalkWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - C8BF89396AF80593D0F773E3 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 1F6D8C2D2B2E3756004376B8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1350,6 +1343,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C8BF89396AF80593D0F773E3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1559,28 +1559,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 404DF6649BF5F70D4316B064 /* TalkWidgets */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9ABE05A230C005038CB734E7 /* Build configuration list for PBXNativeTarget "TalkWidgets" */; - buildPhases = ( - 7B48D333E0BB9346485C467C /* Sources */, - C8BF89396AF80593D0F773E3 /* Frameworks */, - E8B6BD717E91928AEB804805 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 276FE3D714359BBDDFCFD105 /* TalkWidgets */, - ); - name = TalkWidgets; - packageProductDependencies = ( - ); - productName = TalkWidgets; - productReference = A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */; - productType = "com.apple.product-type.app-extension"; - }; 1F6D8C2F2B2E3756004376B8 /* NextcloudTalkTests */ = { isa = PBXNativeTarget; buildConfigurationList = 1F6D8C362B2E3756004376B8 /* Build configuration list for PBXNativeTarget "NextcloudTalkTests" */; @@ -1859,6 +1837,28 @@ productReference = 2CC0014F24A1F0E900A20167 /* NotificationServiceExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 404DF6649BF5F70D4316B064 /* TalkWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9ABE05A230C005038CB734E7 /* Build configuration list for PBXNativeTarget "TalkWidgets" */; + buildPhases = ( + 7B48D333E0BB9346485C467C /* Sources */, + C8BF89396AF80593D0F773E3 /* Frameworks */, + E8B6BD717E91928AEB804805 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 276FE3D714359BBDDFCFD105 /* TalkWidgets */, + ); + name = TalkWidgets; + packageProductDependencies = ( + ); + productName = TalkWidgets; + productReference = A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1869,9 +1869,6 @@ LastUpgradeCheck = 1320; ORGANIZATIONNAME = ""; TargetAttributes = { - 404DF6649BF5F70D4316B064 = { - CreatedOnToolsVersion = 27.1; - }; 1F6D8C2F2B2E3756004376B8 = { CreatedOnToolsVersion = 15.1; LastSwiftMigration = 1520; @@ -1916,6 +1913,9 @@ LastSwiftMigration = 1220; ProvisioningStyle = Automatic; }; + 404DF6649BF5F70D4316B064 = { + CreatedOnToolsVersion = 27.1; + }; }; }; buildConfigurationList = 2C0574781EDD9E8E00D9E7F2 /* Build configuration list for PBXProject "NextcloudTalk" */; @@ -1996,14 +1996,6 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - E8B6BD717E91928AEB804805 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - AACE32068D5BE4716C35504B /* Localizable.strings in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 1F6D8C2E2B2E3756004376B8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2075,6 +2067,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E8B6BD717E91928AEB804805 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AACE32068D5BE4716C35504B /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2281,13 +2281,6 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 7B48D333E0BB9346485C467C /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 1F6D8C2C2B2E3756004376B8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2376,14 +2369,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7B48D333E0BB9346485C467C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - F9B0708653706C7E88EE41B0 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 404DF6649BF5F70D4316B064 /* TalkWidgets */; - targetProxy = B38B10737B74861948D4B46A /* PBXContainerItemProxy */; - }; 1F6D8C352B2E3756004376B8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; @@ -2414,6 +2409,11 @@ target = 2CC0014E24A1F0E900A20167 /* NotificationServiceExtension */; targetProxy = 2CC0015524A1F0E900A20167 /* PBXContainerItemProxy */; }; + F9B0708653706C7E88EE41B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 404DF6649BF5F70D4316B064 /* TalkWidgets */; + targetProxy = B38B10737B74861948D4B46A /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2540,67 +2540,6 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 90C606693565B566AE1CBA39 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = TalkWidgets/TalkWidgets.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NKUJUXUJ3B; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = TalkWidgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 25.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkWidgets; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = APP_EXTENSION; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 9F99C4F4C1B607E85292FA51 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = TalkWidgets/TalkWidgets.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Nextcloud GmbH (NKUJUXUJ3B)"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NKUJUXUJ3B; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = TalkWidgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 25.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkWidgets; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = APP_EXTENSION; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; 1F6D8C372B2E3756004376B8 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 342600BABD1AD1FCA48B5E59 /* Pods-NextcloudTalkTests.debug.xcconfig */; @@ -3439,18 +3378,70 @@ }; name = Release; }; + 90C606693565B566AE1CBA39 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = TalkWidgets/TalkWidgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TalkWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 25.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = APP_EXTENSION; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9F99C4F4C1B607E85292FA51 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = TalkWidgets/TalkWidgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution: Nextcloud GmbH (NKUJUXUJ3B)"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TalkWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 25.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = APP_EXTENSION; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 9ABE05A230C005038CB734E7 /* Build configuration list for PBXNativeTarget "TalkWidgets" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 90C606693565B566AE1CBA39 /* Debug */, - 9F99C4F4C1B607E85292FA51 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 1F6D8C362B2E3756004376B8 /* Build configuration list for PBXNativeTarget "NextcloudTalkTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3523,6 +3514,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 9ABE05A230C005038CB734E7 /* Build configuration list for PBXNativeTarget "TalkWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 90C606693565B566AE1CBA39 /* Debug */, + 9F99C4F4C1B607E85292FA51 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ From 09e01658dc940db4f0526d5b0667e17172fe760a Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sun, 25 Oct 2026 20:51:53 +0100 Subject: [PATCH 2/3] feat(watch): Add an Apple Watch app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new watchOS app (watchOS 10 and later, TalkWatch target), embedded in the iOS app: - The conversations of the active account, with unread and mention badges, ongoing calls and a preview of the last message - A conversation shows its last messages and their reactions - Reply by dictation, Scribble or the keyboard, or with a quick reply (👍, OK, Thanks!, On my way, I'll call you later) - Tap a message to react to it - Opening a conversation marks it as read The watch has no account of its own and never gets any credentials. The iPhone sends it the conversations through WatchConnectivity (application context, updated whenever the rooms change) and does all requests to the server for it (WatchSessionController), waking up in the background if needed. Sensitive conversations don't show a preview of the last message, like in notifications. Incoming calls already show up on the watch through CallKit, where they can be declined. The watch app reuses the AppIcon.icon and the Localizable.strings of the iOS app. In debug builds, the launch arguments -TalkWatchSampleData and -TalkWatchOpenSampleConversation show example data, to try the app in the simulator without a paired iPhone. TalkWatch/** was added to the paths of the build workflow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- .github/workflows/talk-ios-tests.yml | 1 + NextcloudTalk.xcodeproj/project.pbxproj | 172 +++++++++++ NextcloudTalk/AppDelegate.swift | 3 + .../Rooms/WatchSessionController.swift | 266 ++++++++++++++++++ TalkWatch/ConversationListView.swift | 155 ++++++++++ TalkWatch/ConversationView.swift | 188 +++++++++++++ TalkWatch/Info.plist | 30 ++ TalkWatch/TalkWatchApp.swift | 22 ++ TalkWatch/WatchConnection.swift | 163 +++++++++++ TalkWatch/WatchModels.swift | 90 ++++++ 10 files changed, 1090 insertions(+) create mode 100644 NextcloudTalk/Rooms/WatchSessionController.swift create mode 100644 TalkWatch/ConversationListView.swift create mode 100644 TalkWatch/ConversationView.swift create mode 100644 TalkWatch/Info.plist create mode 100644 TalkWatch/TalkWatchApp.swift create mode 100644 TalkWatch/WatchConnection.swift create mode 100644 TalkWatch/WatchModels.swift diff --git a/.github/workflows/talk-ios-tests.yml b/.github/workflows/talk-ios-tests.yml index e94d44b40..ef04e7dfe 100644 --- a/.github/workflows/talk-ios-tests.yml +++ b/.github/workflows/talk-ios-tests.yml @@ -15,6 +15,7 @@ on: - ShareExtension/** - BroadcastUploadExtension/** - TalkWidgets/** + - TalkWatch/** concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index 3c76c50a1..5a723b07a 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -154,14 +154,24 @@ 2CC1C38829C0945700C8436B /* DRCellSlideActionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 2CC1C38529C0945700C8436B /* DRCellSlideActionView.m */; }; 3FCA62550CD1442D28E8A7C6 /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B81BB7A4920C391CC2CACFD /* libPods-NotificationServiceExtension.a */; }; 4890175925A0D7FC2EC76CC0 /* libPods-NextcloudTalkTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7005E22D6C2896927FC3AEEC /* libPods-NextcloudTalkTests.a */; }; + 60904AB5F6A7ED63F1650378 /* TalkWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 79AF6BBB2FB6BF3F65337592 /* TalkWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 80CDF8C42A8E098900CB57AE /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 80CDF8C32A8E098900CB57AE /* SwiftUIIntrospect */; }; 847EFC7236336B67A1A89358 /* libPods-BroadcastUploadExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A3D305FCD7BF7E727A62F35 /* libPods-BroadcastUploadExtension.a */; }; 8789AE73BFCAA413B43319C0 /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 684807120F4439797973DF73 /* libPods-ShareExtension.a */; }; 9993261EDAC77481FF4EF58A /* libPods-NextcloudTalk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F7C31E9D74F550EAF89931B /* libPods-NextcloudTalk.a */; }; + 9E157705099C5A00BFE8168D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2C7F47AC20289B9600081CC7 /* Localizable.strings */; }; AACE32068D5BE4716C35504B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2C7F47AC20289B9600081CC7 /* Localizable.strings */; }; + DA8C554D79C30BF27ABB1864 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2C8D3BAC2FA9DEF900F0A900 /* AppIcon.icon */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 1A4488481868794A3CE91B9A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B243578D27C803D6C01DED36; + remoteInfo = TalkWatch; + }; 1F6D8C342B2E3756004376B8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; @@ -289,6 +299,17 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + F0B0A58CE4AF1B414AC55820 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 60904AB5F6A7ED63F1650378 /* TalkWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -459,6 +480,7 @@ 684807120F4439797973DF73 /* libPods-ShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 6E8E34E233E9B296CE1C3B0B /* Pods-TalkIntents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TalkIntents.release.xcconfig"; path = "Pods/Target Support Files/Pods-TalkIntents/Pods-TalkIntents.release.xcconfig"; sourceTree = ""; }; 7005E22D6C2896927FC3AEEC /* libPods-NextcloudTalkTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NextcloudTalkTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 79AF6BBB2FB6BF3F65337592 /* TalkWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TalkWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 82CD0527E04B844CAD762ADE /* Pods-BroadcastUploadExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BroadcastUploadExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-BroadcastUploadExtension/Pods-BroadcastUploadExtension.debug.xcconfig"; sourceTree = ""; }; 86A9CB1D6A94A44E9B016584 /* Pods-TalkIntents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TalkIntents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-TalkIntents/Pods-TalkIntents.debug.xcconfig"; sourceTree = ""; }; 95D756208A81284B975853EC /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = ""; }; @@ -986,6 +1008,20 @@ ); target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; }; + 55B85C4AD2523E0CE4C8035B /* Exceptions for "TalkWatch" folder in "NextcloudTalk" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WatchModels.swift, + ); + target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; + }; + 5A5A7D9B2864BEF0BFA7FB55 /* Exceptions for "TalkWatch" folder in "TalkWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B243578D27C803D6C01DED36 /* TalkWatch */; + }; 8B65C957AD2D5B226E3E451F /* Exceptions for "TalkWidgets" folder in "NextcloudTalk" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -1206,6 +1242,15 @@ path = TalkWidgets; sourceTree = ""; }; + 36AA1A303C9AB372AB76911B /* TalkWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 55B85C4AD2523E0CE4C8035B /* Exceptions for "TalkWatch" folder in "NextcloudTalk" target */, + 5A5A7D9B2864BEF0BFA7FB55 /* Exceptions for "TalkWatch" folder in "TalkWatch" target */, + ); + path = TalkWatch; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1343,6 +1388,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 6B189E9569E86C5505FB8414 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C8BF89396AF80593D0F773E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1362,6 +1414,7 @@ 1F8977F52DB0F76100E8114D /* NextcloudTalkTests */, 1FA93D9D2D70FCC200DF6CDF /* TalkIntents */, 276FE3D714359BBDDFCFD105 /* TalkWidgets */, + 36AA1A303C9AB372AB76911B /* TalkWatch */, 2C05747E1EDD9E8E00D9E7F2 /* Products */, 2C05749C1EDDA01700D9E7F2 /* ThirdParty */, 2C05747F1EDD9E8E00D9E7F2 /* NextcloudTalk */, @@ -1382,6 +1435,7 @@ 1F6D8C302B2E3756004376B8 /* NextcloudTalkTests.xctest */, 1FA93D9C2D70FCC200DF6CDF /* TalkIntents.appex */, A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */, + 79AF6BBB2FB6BF3F65337592 /* TalkWatch.app */, ); name = Products; sourceTree = ""; @@ -1696,6 +1750,7 @@ 2C8035721F950BA800501B5C /* ShellScript */, 2C3F6AB01FBB1ACD00E2705C /* Embed App Extensions */, 1FA93DAA2D70FCC200DF6CDF /* Embed ExtensionKit Extensions */, + F0B0A58CE4AF1B414AC55820 /* Embed Watch Content */, 2C5E72BC27957FCA004ED7FB /* ShellScript */, C21100AE204AFC213989DA96 /* [CP] Embed Pods Frameworks */, 1F899F092EF8698200890727 /* Embed Frameworks */, @@ -1708,6 +1763,7 @@ 1FF2FD712AB99CCB000C9905 /* PBXTargetDependency */, 1FA93DA42D70FCC200DF6CDF /* PBXTargetDependency */, F9B0708653706C7E88EE41B0 /* PBXTargetDependency */, + 8ABBED5F401BCDE7A5BCCAE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 1F2058832CEBE4E700AAA673 /* ShareExtension */, @@ -1859,6 +1915,28 @@ productReference = A06BE6676DC8072EFA0886DF /* TalkWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + B243578D27C803D6C01DED36 /* TalkWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = C63D7341CFFCBB33BDA17813 /* Build configuration list for PBXNativeTarget "TalkWatch" */; + buildPhases = ( + 98528CF320BF47E1DF764CCE /* Sources */, + 6B189E9569E86C5505FB8414 /* Frameworks */, + EE88803CC792652CC7021D63 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 36AA1A303C9AB372AB76911B /* TalkWatch */, + ); + name = TalkWatch; + packageProductDependencies = ( + ); + productName = TalkWatch; + productReference = 79AF6BBB2FB6BF3F65337592 /* TalkWatch.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1869,6 +1947,9 @@ LastUpgradeCheck = 1320; ORGANIZATIONNAME = ""; TargetAttributes = { + B243578D27C803D6C01DED36 = { + CreatedOnToolsVersion = 27.1; + }; 1F6D8C2F2B2E3756004376B8 = { CreatedOnToolsVersion = 15.1; LastSwiftMigration = 1520; @@ -1991,6 +2072,7 @@ 1F6D8C2F2B2E3756004376B8 /* NextcloudTalkTests */, 1FA93D9B2D70FCC200DF6CDF /* TalkIntents */, 404DF6649BF5F70D4316B064 /* TalkWidgets */, + B243578D27C803D6C01DED36 /* TalkWatch */, ); }; /* End PBXProject section */ @@ -2075,6 +2157,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EE88803CC792652CC7021D63 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8C554D79C30BF27ABB1864 /* AppIcon.icon in Resources */, + 9E157705099C5A00BFE8168D /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2376,6 +2467,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 98528CF320BF47E1DF764CCE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2409,6 +2507,11 @@ target = 2CC0014E24A1F0E900A20167 /* NotificationServiceExtension */; targetProxy = 2CC0015524A1F0E900A20167 /* PBXContainerItemProxy */; }; + 8ABBED5F401BCDE7A5BCCAE8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B243578D27C803D6C01DED36 /* TalkWatch */; + targetProxy = 1A4488481868794A3CE91B9A /* PBXContainerItemProxy */; + }; F9B0708653706C7E88EE41B0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 404DF6649BF5F70D4316B064 /* TalkWidgets */; @@ -3378,6 +3481,35 @@ }; name = Release; }; + 4202E6DC5519575E4D2AD024 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TalkWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 25.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCH_COMPANION_APP_BUNDLE_IDENTIFIER = com.nextcloud.Talk; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; 90C606693565B566AE1CBA39 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3439,6 +3571,37 @@ }; name = Release; }; + E05142399061BD88389E5CC2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TalkWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 25.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCH_COMPANION_APP_BUNDLE_IDENTIFIER = com.nextcloud.Talk; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3523,6 +3686,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C63D7341CFFCBB33BDA17813 /* Build configuration list for PBXNativeTarget "TalkWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E05142399061BD88389E5CC2 /* Debug */, + 4202E6DC5519575E4D2AD024 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/NextcloudTalk/AppDelegate.swift b/NextcloudTalk/AppDelegate.swift index a0298ec7d..fb056f591 100644 --- a/NextcloudTalk/AppDelegate.swift +++ b/NextcloudTalk/AppDelegate.swift @@ -79,6 +79,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, PKPushRegistryDelegate { ConversationsWidgetController.shared.start() ConversationsSpotlightIndexer.shared.start() + // Talk on the Apple Watch + WatchSessionController.shared.start() + self.registerBackgroundFetchTask() self.registerBackgroundProcessingTask() diff --git a/NextcloudTalk/Rooms/WatchSessionController.swift b/NextcloudTalk/Rooms/WatchSessionController.swift new file mode 100644 index 000000000..441580e53 --- /dev/null +++ b/NextcloudTalk/Rooms/WatchSessionController.swift @@ -0,0 +1,266 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import WatchConnectivity + +// Talk on the Apple Watch: sends the conversations of the active account to the watch and does the work on the +// server for it, see WatchModels.swift. The watch never gets any credentials. +class WatchSessionController: NSObject { + + public static let shared = WatchSessionController() + + // A snapshot in the application context needs to stay small + private let maxConversations = 25 + private let maxPreviewLength = 120 + private let messagesLimit = 25 + + private var pendingUpdate: DispatchWorkItem? + private var lastSentSnapshot: WatchSnapshot? + + func start() { + guard WCSession.isSupported() else { return } + + WCSession.default.delegate = self + WCSession.default.activate() + + NotificationCenter.default.addObserver(self, selector: #selector(setNeedsUpdate), name: .NCRoomsManagerDidUpdateRooms, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(setNeedsUpdate), name: .NCSettingsControllerDidChangeActiveAccount, object: nil) + } + + private var canSendToWatch: Bool { + let session = WCSession.default + return session.activationState == .activated && session.isPaired && session.isWatchAppInstalled + } + + // Rooms are often updated several times in a row, so the update is slightly delayed to only do it once + @objc func setNeedsUpdate() { + DispatchQueue.main.async { + self.pendingUpdate?.cancel() + + let update = DispatchWorkItem { + self.sendSnapshot(force: false) + } + + self.pendingUpdate = update + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: update) + } + } + + // MARK: - Snapshot + + private func currentSnapshot() -> WatchSnapshot? { + guard NCDatabaseManager.sharedInstance().numberOfAccounts() > 0 else { return nil } + + let account = NCDatabaseManager.sharedInstance().activeAccount() + let rooms = NCDatabaseManager.sharedInstance().roomsForAccountId(account.accountId, withRealm: nil) + .filter { !$0.isArchived } + .sorted { $0.lastActivity > $1.lastActivity } + .prefix(maxConversations) + + let conversations = rooms.map { room in + var lastMessage: String? + + // Like in notifications, sensitive conversations don't show a preview + if !room.isSensitive, let preview = room.lastMessageString?.string { + lastMessage = String(preview.prefix(maxPreviewLength)) + } + + return WatchConversation(token: room.token, + displayName: room.displayName, + lastMessage: lastMessage, + lastActivity: Date(timeIntervalSince1970: TimeInterval(room.lastActivity)), + unreadMessages: room.unreadMessages, + unreadMention: room.unreadMention, + hasCall: room.hasCall, + isFavorite: room.isFavorite, + isOneToOne: room.isOneToOne, + canReact: room.canReact) + } + + let serverHost = URL(string: account.server)?.host ?? account.server + return WatchSnapshot(accountName: "\(account.userDisplayName) (\(serverHost))", conversations: Array(conversations)) + } + + private func sendSnapshot(force: Bool) { + guard canSendToWatch else { return } + + let snapshot = self.currentSnapshot() + + guard force || snapshot != lastSentSnapshot else { return } + + var context: [String: Any] = [:] + + if let snapshot, let data = WatchRequest.encode(snapshot) { + context[WatchRequest.snapshotKey] = data + } + + // Makes sure the watch takes it, even if nothing changed + context["date"] = Date().timeIntervalSince1970 + + do { + try WCSession.default.updateApplicationContext(context) + self.lastSentSnapshot = snapshot + } catch { + NCLog.log("Could not send conversations to the watch: \(error.localizedDescription)") + } + } + + // MARK: - Requests from the watch + + private func handle(_ message: [String: Any], reply: @escaping ([String: Any]) -> Void) { + guard let actionString = message[WatchRequest.actionKey] as? String, + let action = WatchRequest.Action(rawValue: actionString) + else { + reply([WatchRequest.errorKey: "Unknown request"]) + return + } + + guard NCDatabaseManager.sharedInstance().numberOfAccounts() > 0 else { + reply([WatchRequest.errorKey: NSLocalizedString("Account not found", comment: "")]) + return + } + + let account = NCDatabaseManager.sharedInstance().activeAccount() + let token = message[WatchRequest.tokenKey] as? String ?? "" + + // The watch can wake the app up in the background, make sure the request can finish + var backgroundTask = UIBackgroundTaskIdentifier.invalid + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "WatchRequest") { + UIApplication.shared.endBackgroundTask(backgroundTask) + } + + let finish: ([String: Any]) -> Void = { response in + reply(response) + + DispatchQueue.main.async { + UIApplication.shared.endBackgroundTask(backgroundTask) + } + } + + let finishWithError: (Error?) -> Void = { error in + if let error { + finish([WatchRequest.errorKey: error.localizedDescription]) + } else { + finish([:]) + } + } + + switch action { + case .refresh: + self.sendSnapshot(force: true) + NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false) + finish([:]) + + case .messages: + self.loadMessages(in: token, forAccount: account) { messages, error in + if let messages, let data = WatchRequest.encode(messages) { + finish([WatchRequest.messagesKey: data]) + } else { + finishWithError(error) + } + } + + case .send: + let text = message[WatchRequest.textKey] as? String ?? "" + + NCAPIController.sharedInstance().sendChatMessage(text, toRoom: token, threadTitle: nil, replyTo: -1, referenceId: nil, + silently: false, forAccount: account) { error in + finishWithError(error) + NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false) + } + + case .react: + let messageId = (message[WatchRequest.messageIdKey] as? NSNumber)?.intValue ?? 0 + let reaction = message[WatchRequest.reactionKey] as? String ?? "" + + NCAPIController.sharedInstance().addReaction(reaction, toMessage: messageId, inRoom: token, forAccount: account) { _, error in + finishWithError(error) + } + + case .markAsRead: + let lastMessageId = NCDatabaseManager.sharedInstance().room(withToken: token, forAccountId: account.accountId)?.lastMessage?.messageId ?? 0 + + NCAPIController.sharedInstance().setChatReadMarker(lastMessageId, inRoom: token, forAccount: account) { error in + finishWithError(error) + NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false) + } + } + } + + private func loadMessages(in token: String, forAccount account: TalkAccount, completion: @escaping ([WatchMessage]?, Error?) -> Void) { + let lastMessageId = NCDatabaseManager.sharedInstance().room(withToken: token, forAccountId: account.accountId)?.lastMessage?.messageId ?? 0 + + _ = NCAPIController.sharedInstance().receiveChatMessages(ofRoom: token, fromLastMessageId: lastMessageId, inThread: 0, history: true, + includeLastMessage: true, timeout: false, limit: messagesLimit, + lastCommonReadMessage: 0, setReadMarker: false, markNotificationsAsRead: false, + forAccount: account) { messageDicts, _, _, error, _ in + guard let messageDicts else { + completion(nil, error) + return + } + + let messages = messageDicts + .compactMap { NCChatMessage(dictionary: $0, andAccountId: account.accountId) } + .filter { !$0.isSystemMessage && !$0.isDeletedMessage } + .sorted { $0.messageId < $1.messageId } + .map { self.watchMessage(for: $0, account: account) } + + completion(messages, nil) + } + } + + private func watchMessage(for message: NCChatMessage, account: TalkAccount) -> WatchMessage { + var text: String + + if message.isVoiceMessage { + text = "🎤 " + NSLocalizedString("Voice message", comment: "") + } else if let poll = message.poll { + text = "📊 " + poll.name + } else if let file = message.file() { + text = "📎 " + file.name + } else { + text = message.parsedMessage()?.string ?? message.message ?? "" + } + + let reactions = message.reactionsArray().map { reaction in + WatchReaction(reaction: reaction.reaction, count: reaction.count, reactedBySelf: reaction.userReacted) + } + + return WatchMessage(id: Int64(message.messageId), + author: message.actorDisplayName ?? "", + text: text, + date: Date(timeIntervalSince1970: TimeInterval(message.timestamp)), + isOwnMessage: message.actorId == account.userId && message.actorType == "users", + reactions: reactions) + } +} + +extension WatchSessionController: WCSessionDelegate { + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if activationState == .activated { + self.setNeedsUpdate() + } + } + + func sessionWatchStateDidChange(_ session: WCSession) { + // E.g. the watch app was just installed + self.setNeedsUpdate() + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + // Switching to another watch + WCSession.default.activate() + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + DispatchQueue.main.async { + self.handle(message, reply: replyHandler) + } + } +} diff --git a/TalkWatch/ConversationListView.swift b/TalkWatch/ConversationListView.swift new file mode 100644 index 000000000..8ddbf14f5 --- /dev/null +++ b/TalkWatch/ConversationListView.swift @@ -0,0 +1,155 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +struct ConversationListView: View { + + @EnvironmentObject private var connection: WatchConnection + + @State private var path: [WatchConversation] = [] + + var body: some View { + NavigationStack(path: $path) { + Group { + if let snapshot = connection.snapshot { + if snapshot.conversations.isEmpty { + PlaceholderView(systemImage: "bubble.left.and.bubble.right", + text: NSLocalizedString("No conversations yet", comment: "")) + } else { + List(snapshot.conversations) { conversation in + NavigationLink(value: conversation) { + ConversationRow(conversation: conversation) + } + } + } + } else if connection.hasFinishedActivation { + PlaceholderView(systemImage: "iphone", + text: NSLocalizedString("Log in to Nextcloud Talk on your iPhone to see your conversations here", comment: "Shown on the Apple Watch")) + } else { + ProgressView() + } + } + .navigationTitle("Talk") + .navigationDestination(for: WatchConversation.self) { conversation in + ConversationView(conversation: conversation) + } + .onAppear { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("-TalkWatchOpenSampleConversation"), path.isEmpty, + let conversation = WatchSnapshot.sample.conversations.first { + path = [conversation] + } + #endif + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await connection.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(NSLocalizedString("Refresh", comment: "")) + } + } + } + } +} + +struct ConversationRow: View { + + let conversation: WatchConversation + + var body: some View { + HStack(spacing: 8) { + InitialsAvatar(name: conversation.displayName, size: 30) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(conversation.displayName) + .font(.headline) + .fontWeight(conversation.unreadMessages > 0 ? .bold : .regular) + .lineLimit(1) + + if conversation.hasCall { + Image(systemName: "video.fill") + .font(.caption2) + .foregroundStyle(.green) + } + } + + if let lastMessage = conversation.lastMessage, !lastMessage.isEmpty { + Text(lastMessage) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + + if conversation.unreadMessages > 0 { + Text(conversation.unreadMention ? "@" : (conversation.unreadMessages > 99 ? "99+" : "\(conversation.unreadMessages)")) + .font(.caption2.bold()) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(conversation.unreadMention ? Color.talkAccent : Color.gray.opacity(0.4))) + } + } + .accessibilityElement(children: .combine) + } +} + +struct InitialsAvatar: View { + + let name: String + let size: CGFloat + + private static let colors: [Color] = [.blue, .green, .orange, .pink, .purple, .teal, .indigo, .red] + + var body: some View { + ZStack { + Circle().fill(Self.colors[colorIndex]) + Text(initials) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private var initials: String { + return name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined().uppercased() + } + + // Same colors as in the widgets + private var colorIndex: Int { + return name.unicodeScalars.reduce(0) { $0 + Int($1.value) } % Self.colors.count + } +} + +struct PlaceholderView: View { + + let systemImage: String + let text: String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: systemImage) + .font(.title2) + .foregroundStyle(.secondary) + Text(text) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() + } +} + +extension Color { + // The Nextcloud blue + static let talkAccent = Color(red: 0, green: 130 / 255, blue: 201 / 255) +} diff --git a/TalkWatch/ConversationView.swift b/TalkWatch/ConversationView.swift new file mode 100644 index 000000000..d4ec78a6a --- /dev/null +++ b/TalkWatch/ConversationView.swift @@ -0,0 +1,188 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +struct ConversationView: View { + + @EnvironmentObject private var connection: WatchConnection + + let conversation: WatchConversation + + @State private var messages: [WatchMessage] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var isSending = false + @State private var reactingTo: WatchMessage? + + private let quickReplies = [ + "👍", + NSLocalizedString("OK", comment: "Quick reply on the Apple Watch"), + NSLocalizedString("Thanks!", comment: "Quick reply on the Apple Watch"), + NSLocalizedString("On my way", comment: "Quick reply on the Apple Watch"), + NSLocalizedString("I'll call you later", comment: "Quick reply on the Apple Watch") + ] + + var body: some View { + Group { + // The list is only created once the messages are there, so it starts at the bottom + if isLoading, messages.isEmpty, errorMessage == nil { + ProgressView() + } else { + messageList + } + } + .navigationTitle(conversation.displayName) + .task { + await load() + await connection.markAsRead(conversation) + } + .sheet(item: $reactingTo) { message in + ReactionPicker { reaction in + reactingTo = nil + react(with: reaction, to: message) + } + } + } + + private var messageList: some View { + ScrollViewReader { proxy in + List { + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + + ForEach(messages) { message in + MessageRow(message: message) + .id(message.id) + .onTapGesture { + if conversation.canReact { + reactingTo = message + } + } + } + + Section { + TextFieldLink(prompt: Text(NSLocalizedString("Reply", comment: ""))) { + Label(NSLocalizedString("Reply", comment: ""), systemImage: "mic.fill") + } onSubmit: { text in + send(text) + } + .disabled(isSending) + + ForEach(quickReplies, id: \.self) { reply in + Button(reply) { + send(reply) + } + .disabled(isSending) + } + } + } + .onAppear { + // Start with the newest message + DispatchQueue.main.async { + proxy.scrollTo(messages.last?.id, anchor: .top) + } + } + } + } + + private func load() async { + isLoading = true + + do { + messages = try await connection.messages(in: conversation) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func send(_ text: String) { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return } + + isSending = true + + Task { + do { + try await connection.send(trimmedText, to: conversation) + await load() + } catch { + errorMessage = error.localizedDescription + } + + isSending = false + } + } + + private func react(with reaction: String, to message: WatchMessage) { + Task { + do { + try await connection.react(with: reaction, to: message, in: conversation) + await load() + } catch { + errorMessage = error.localizedDescription + } + } + } +} + +struct MessageRow: View { + + let message: WatchMessage + + var body: some View { + VStack(alignment: message.isOwnMessage ? .trailing : .leading, spacing: 2) { + if !message.isOwnMessage { + Text(message.author) + .font(.caption2) + .foregroundStyle(Color.talkAccent) + .lineLimit(1) + } + + Text(message.text) + .font(.footnote) + + HStack(spacing: 4) { + ForEach(message.reactions, id: \.reaction) { reaction in + Text("\(reaction.reaction) \(reaction.count)") + .font(.caption2) + .padding(.horizontal, 4) + .background(Capsule().fill(reaction.reactedBySelf ? Color.talkAccent.opacity(0.5) : Color.gray.opacity(0.3))) + } + + Text(message.date, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: message.isOwnMessage ? .trailing : .leading) + .accessibilityElement(children: .combine) + } +} + +struct ReactionPicker: View { + + let onSelect: (String) -> Void + + private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(WatchRequest.reactions, id: \.self) { reaction in + Button(reaction) { + onSelect(reaction) + } + .font(.title2) + } + } + .padding(.horizontal) + } +} diff --git a/TalkWatch/Info.plist b/TalkWatch/Info.plist new file mode 100644 index 000000000..f71d75246 --- /dev/null +++ b/TalkWatch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Talk + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + $(WATCH_COMPANION_APP_BUNDLE_IDENTIFIER) + WKRunsIndependentlyOfCompanionApp + + + diff --git a/TalkWatch/TalkWatchApp.swift b/TalkWatch/TalkWatchApp.swift new file mode 100644 index 000000000..30d52815c --- /dev/null +++ b/TalkWatch/TalkWatchApp.swift @@ -0,0 +1,22 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +@main +struct TalkWatchApp: App { + + @StateObject private var connection = WatchConnection.shared + + var body: some Scene { + WindowGroup { + ConversationListView() + .environmentObject(connection) + .onAppear { + connection.activate() + } + } + } +} diff --git a/TalkWatch/WatchConnection.swift b/TalkWatch/WatchConnection.swift new file mode 100644 index 000000000..c23e27a1a --- /dev/null +++ b/TalkWatch/WatchConnection.swift @@ -0,0 +1,163 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import WatchConnectivity + +// The connection to Talk on the paired iPhone, which does all the work on the server +@MainActor +final class WatchConnection: NSObject, ObservableObject { + + static let shared = WatchConnection() + + @Published private(set) var snapshot: WatchSnapshot? + // Also when the activation failed, e.g. because Talk is not installed on the iPhone + @Published private(set) var hasFinishedActivation = false + + enum ConnectionError: LocalizedError { + case notReachable + case failed(String) + + var errorDescription: String? { + switch self { + case .notReachable: + return NSLocalizedString("Your iPhone is not reachable", comment: "Shown on the Apple Watch") + case .failed(let message): + return message + } + } + } + + func activate() { + #if DEBUG + // Example conversations and messages, to try the app without an iPhone, e.g. in the simulator + if ProcessInfo.processInfo.arguments.contains("-TalkWatchSampleData") { + self.snapshot = .sample + self.hasFinishedActivation = true + return + } + #endif + + guard WCSession.isSupported(), WCSession.default.activationState != .activated else { return } + + WCSession.default.delegate = self + WCSession.default.activate() + } + + // MARK: - Requests + + func refresh() async { + // The answer comes as a new application context + _ = try? await self.request(.refresh) + } + + func messages(in conversation: WatchConversation) async throws -> [WatchMessage] { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("-TalkWatchSampleData") { + return WatchMessage.samples + } + #endif + + let reply = try await self.request(.messages, [WatchRequest.tokenKey: conversation.token]) + return WatchRequest.decode([WatchMessage].self, from: reply[WatchRequest.messagesKey] as? Data) ?? [] + } + + func send(_ text: String, to conversation: WatchConversation) async throws { + _ = try await self.request(.send, [WatchRequest.tokenKey: conversation.token, WatchRequest.textKey: text]) + } + + func react(with reaction: String, to message: WatchMessage, in conversation: WatchConversation) async throws { + _ = try await self.request(.react, [WatchRequest.tokenKey: conversation.token, + WatchRequest.messageIdKey: NSNumber(value: message.id), + WatchRequest.reactionKey: reaction]) + } + + func markAsRead(_ conversation: WatchConversation) async { + _ = try? await self.request(.markAsRead, [WatchRequest.tokenKey: conversation.token]) + } + + private func request(_ action: WatchRequest.Action, _ parameters: [String: Any] = [:]) async throws -> [String: Any] { + let session = WCSession.default + + guard session.activationState == .activated, session.isReachable else { + throw ConnectionError.notReachable + } + + var message = parameters + message[WatchRequest.actionKey] = action.rawValue + + let reply: [String: Any] = try await withCheckedThrowingContinuation { continuation in + session.sendMessage(message, replyHandler: { reply in + continuation.resume(returning: reply) + }, errorHandler: { error in + continuation.resume(throwing: ConnectionError.failed(error.localizedDescription)) + }) + } + + if let errorMessage = reply[WatchRequest.errorKey] as? String { + throw ConnectionError.failed(errorMessage) + } + + return reply + } + + // MARK: - Snapshot + + fileprivate func applyApplicationContext(_ context: [String: Any]) { + // An empty snapshot means no account is logged in on the iPhone + guard let data = context[WatchRequest.snapshotKey] as? Data else { + self.snapshot = nil + return + } + + if let snapshot = WatchRequest.decode(WatchSnapshot.self, from: data) { + self.snapshot = snapshot + } + } +} + +extension WatchConnection: WCSessionDelegate { + + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + let context = session.receivedApplicationContext + + Task { @MainActor in + self.hasFinishedActivation = true + self.applyApplicationContext(context) + + if activationState == .activated { + await self.refresh() + } + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + Task { @MainActor in + self.applyApplicationContext(applicationContext) + } + } +} + +#if DEBUG +extension WatchSnapshot { + static let sample = WatchSnapshot(accountName: "Alice (cloud.example.com)", conversations: [ + WatchConversation(token: "1", displayName: "Design team", lastMessage: "Bob: The new icons look great 🎉", lastActivity: Date(), + unreadMessages: 3, unreadMention: true, hasCall: false, isFavorite: true, isOneToOne: false, canReact: true), + WatchConversation(token: "2", displayName: "Carol", lastMessage: "See you at 3?", lastActivity: Date().addingTimeInterval(-600), + unreadMessages: 1, unreadMention: false, hasCall: false, isFavorite: true, isOneToOne: true, canReact: true), + WatchConversation(token: "3", displayName: "Weekly sync", lastMessage: "Call started", lastActivity: Date().addingTimeInterval(-3600), + unreadMessages: 0, unreadMention: false, hasCall: true, isFavorite: false, isOneToOne: false, canReact: true) + ]) +} + +extension WatchMessage { + static let samples = [ + WatchMessage(id: 1, author: "Bob", text: "I pushed the new icons", date: Date().addingTimeInterval(-900), isOwnMessage: false, reactions: []), + WatchMessage(id: 2, author: "Alice", text: "Nice, I'll have a look", date: Date().addingTimeInterval(-800), isOwnMessage: true, reactions: []), + WatchMessage(id: 3, author: "Bob", text: "The new icons look great 🎉", date: Date().addingTimeInterval(-60), isOwnMessage: false, + reactions: [WatchReaction(reaction: "👍", count: 2, reactedBySelf: true)]) + ] +} +#endif diff --git a/TalkWatch/WatchModels.swift b/TalkWatch/WatchModels.swift new file mode 100644 index 000000000..25432a678 --- /dev/null +++ b/TalkWatch/WatchModels.swift @@ -0,0 +1,90 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +// What the iPhone and the Apple Watch exchange through WatchConnectivity. +// The watch has no account of its own: the iPhone sends it the conversations and does everything on the +// server for it (loading messages, sending replies and reactions), so no credentials are stored on the watch. +// This file is part of the app and of the watch app. + +struct WatchConversation: Codable, Identifiable, Hashable { + var id: String { token } + + let token: String + let displayName: String + // Nil for sensitive conversations, the preview is not shown there + let lastMessage: String? + let lastActivity: Date + let unreadMessages: Int + let unreadMention: Bool + let hasCall: Bool + let isFavorite: Bool + let isOneToOne: Bool + let canReact: Bool +} + +struct WatchSnapshot: Codable, Equatable { + // E.g. "Alice (cloud.example.com)", shown when nothing else is there + let accountName: String + let conversations: [WatchConversation] +} + +struct WatchMessage: Codable, Identifiable, Hashable { + // Int64, as Int only has 32 bits on the Apple Watch + let id: Int64 + let author: String + let text: String + let date: Date + let isOwnMessage: Bool + // Reaction and how often it was given, in the order shown below the message + let reactions: [WatchReaction] +} + +struct WatchReaction: Codable, Hashable { + let reaction: String + let count: Int + let reactedBySelf: Bool +} + +// Requests from the watch, answered by the iPhone +enum WatchRequest { + static let actionKey = "action" + static let tokenKey = "token" + static let textKey = "text" + static let messageIdKey = "messageId" + static let reactionKey = "reaction" + + // Reply keys + static let snapshotKey = "snapshot" + static let messagesKey = "messages" + static let errorKey = "error" + + enum Action: String { + // Asks the iPhone to send a fresh snapshot of the conversations + case refresh + // Returns the last messages of a conversation + case messages + case send + case react + case markAsRead + } + + static let reactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"] + + static func encode(_ value: T) -> Data? { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return try? encoder.encode(value) + } + + static func decode(_ type: T.Type, from data: Data?) -> T? { + guard let data else { return nil } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return try? decoder.decode(type, from: data) + } +} From a448880b52f021dca0ff3369d0a91fb41a799497 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sun, 25 Oct 2026 21:39:32 +0100 Subject: [PATCH 3/3] test: Add a test for the models exchanged with the Apple Watch Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- .../Unit/UnitWatchModelsTest.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 NextcloudTalkTests/Unit/UnitWatchModelsTest.swift diff --git a/NextcloudTalkTests/Unit/UnitWatchModelsTest.swift b/NextcloudTalkTests/Unit/UnitWatchModelsTest.swift new file mode 100644 index 000000000..3f0f1c819 --- /dev/null +++ b/NextcloudTalkTests/Unit/UnitWatchModelsTest.swift @@ -0,0 +1,26 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +final class UnitWatchModelsTest: XCTestCase { + + func testWatchModelsRoundTrip() throws { + let snapshot = WatchSnapshot(accountName: "Alice (cloud.example.com)", conversations: [ + WatchConversation(token: "t1", displayName: "Design", lastMessage: "Bob: Hi", lastActivity: Date(timeIntervalSince1970: 1000), + unreadMessages: 2, unreadMention: true, hasCall: false, isFavorite: true, isOneToOne: false, canReact: true) + ]) + let messages = [ + WatchMessage(id: 1, author: "Bob", text: "Hi 👋", date: Date(timeIntervalSince1970: 2000), isOwnMessage: false, + reactions: [WatchReaction(reaction: "👍", count: 2, reactedBySelf: true)]) + ] + + XCTAssertEqual(WatchRequest.decode(WatchSnapshot.self, from: WatchRequest.encode(snapshot)), snapshot) + XCTAssertEqual(WatchRequest.decode([WatchMessage].self, from: WatchRequest.encode(messages)), messages) + XCTAssertNil(WatchRequest.decode(WatchSnapshot.self, from: nil)) + XCTAssertNil(WatchRequest.decode(WatchSnapshot.self, from: Data("garbage".utf8))) + } +}