commit 80de9fe0573c158c16a42ea70d3e5d4150fb463a Author: jared Date: Mon Jan 19 22:06:32 2026 -0500 Initial commit AtTable iOS app with multipeer connectivity for mesh messaging. Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5c173c --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Xcode +build/ +DerivedData/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 +xcuserdata/ + +# Xcode Scheme +*.xcscheme + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# CocoaPods +Pods/ +Podfile.lock + +# Carthage +Carthage/Build/ +Carthage/Checkouts/ + +# Node +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Thumbnails +Thumbs.db + +# IDE +.idea/ +*.swp +*.swo +*~ + +# Archives +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Environment files +.env +.env.local +.env.*.local diff --git a/At-Table_orig.png b/At-Table_orig.png new file mode 100644 index 0000000..d308d52 Binary files /dev/null and b/At-Table_orig.png differ diff --git a/AtTable.xcodeproj/project.pbxproj b/AtTable.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9e43e3a --- /dev/null +++ b/AtTable.xcodeproj/project.pbxproj @@ -0,0 +1,357 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 7F9DE17C2EFC324800008582 /* AtTable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtTable.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7F9DE17E2EFC324800008582 /* AtTable */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AtTable; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7F9DE1792EFC324800008582 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7F9DE1732EFC324800008582 = { + isa = PBXGroup; + children = ( + 7F9DE17E2EFC324800008582 /* AtTable */, + 7F9DE17D2EFC324800008582 /* Products */, + ); + sourceTree = ""; + }; + 7F9DE17D2EFC324800008582 /* Products */ = { + isa = PBXGroup; + children = ( + 7F9DE17C2EFC324800008582 /* AtTable.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7F9DE17B2EFC324800008582 /* AtTable */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F9DE1872EFC324800008582 /* Build configuration list for PBXNativeTarget "AtTable" */; + buildPhases = ( + 7F9DE1782EFC324800008582 /* Sources */, + 7F9DE1792EFC324800008582 /* Frameworks */, + 7F9DE17A2EFC324800008582 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7F9DE17E2EFC324800008582 /* AtTable */, + ); + name = AtTable; + packageProductDependencies = ( + ); + productName = AtTable; + productReference = 7F9DE17C2EFC324800008582 /* AtTable.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7F9DE1742EFC324800008582 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 7F9DE17B2EFC324800008582 = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 7F9DE1772EFC324800008582 /* Build configuration list for PBXProject "AtTable" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7F9DE1732EFC324800008582; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7F9DE17D2EFC324800008582 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7F9DE17B2EFC324800008582 /* AtTable */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7F9DE17A2EFC324800008582 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7F9DE1782EFC324800008582 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7F9DE1852EFC324800008582 /* 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 = 7X85543FQQ; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7F9DE1862EFC324800008582 /* 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 = 7X85543FQQ; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7F9DE1882EFC324800008582 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MeshInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = "At-Table"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We use the local network to find and connect with other devices for chat."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "We need access to your microphone to transcribe your speech."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "We use speech recognition to convert your speech into text for the chat."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7F9DE1892EFC324800008582 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7X85543FQQ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MeshInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = "At-Table"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We use the local network to find and connect with other devices for chat."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "We need access to your microphone to transcribe your speech."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "We use speech recognition to convert your speech into text for the chat."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7F9DE1772EFC324800008582 /* Build configuration list for PBXProject "AtTable" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F9DE1852EFC324800008582 /* Debug */, + 7F9DE1862EFC324800008582 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F9DE1872EFC324800008582 /* Build configuration list for PBXNativeTarget "AtTable" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F9DE1882EFC324800008582 /* Debug */, + 7F9DE1892EFC324800008582 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7F9DE1742EFC324800008582 /* Project object */; +} diff --git a/AtTable.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AtTable.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/AtTable.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AtTable/Assets.xcassets/AccentColor.colorset/Contents.json b/AtTable/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/AtTable/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/100.png b/AtTable/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..fc27465 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/1024.png b/AtTable/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..89549d8 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/114.png b/AtTable/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..19be516 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/120.png b/AtTable/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..23426b8 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/144.png b/AtTable/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..d59ea07 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/152.png b/AtTable/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..e0ed1b6 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/167.png b/AtTable/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..6eb44c5 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/180.png b/AtTable/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..26b8a64 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/20.png b/AtTable/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..54e61e1 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/29.png b/AtTable/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..ee3eddc Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/40.png b/AtTable/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..61a5a5a Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/50.png b/AtTable/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..b3f93d3 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/57.png b/AtTable/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..6c7210b Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/58.png b/AtTable/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..834f349 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/60.png b/AtTable/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..221e520 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/72.png b/AtTable/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..010bade Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/76.png b/AtTable/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..3ee05f7 Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/80.png b/AtTable/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..836ac1b Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/87.png b/AtTable/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..5adbe7a Binary files /dev/null and b/AtTable/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/AtTable/Assets.xcassets/AppIcon.appiconset/Contents.json b/AtTable/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..65b74d7 --- /dev/null +++ b/AtTable/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]} \ No newline at end of file diff --git a/AtTable/Assets.xcassets/Contents.json b/AtTable/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/AtTable/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AtTable/Assets.xcassets/qrcode.imageset/Contents.json b/AtTable/Assets.xcassets/qrcode.imageset/Contents.json new file mode 100644 index 0000000..dc62874 --- /dev/null +++ b/AtTable/Assets.xcassets/qrcode.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "qrcode.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AtTable/Assets.xcassets/qrcode.imageset/qrcode.png b/AtTable/Assets.xcassets/qrcode.imageset/qrcode.png new file mode 100644 index 0000000..13abd50 Binary files /dev/null and b/AtTable/Assets.xcassets/qrcode.imageset/qrcode.png differ diff --git a/AtTable/AtTableApp.swift b/AtTable/AtTableApp.swift new file mode 100644 index 0000000..562a918 --- /dev/null +++ b/AtTable/AtTableApp.swift @@ -0,0 +1,47 @@ +// +// AtTableApp.swift +// AtTable +// +// Created by Jared Evans on 12/24/25. +// + +import SwiftUI + +@main +struct AtTableApp: App { + @AppStorage("isOnboardingComplete") var isOnboardingComplete: Bool = false + @AppStorage("userName") var userName: String = "" + @AppStorage("userRole") var userRole: UserRole = .hearing + @AppStorage("userColorHex") var userColorHex: String = "#00008B" + + // APP-LEVEL SESSION: Ensures stable identity (Instance ID) across view reloads + @StateObject var multipeerSession = MultipeerSession() + + init() { + // Reset navigation state on launch so users always see the "Login" screen first. + // This prevents getting "stuck" in a connecting loop if the app crashed. + UserDefaults.standard.set(false, forKey: "isOnboardingComplete") + } + + var body: some Scene { + WindowGroup { + Group { + if isOnboardingComplete { + ChatView( + userName: userName, + userRole: userRole, + userColorHex: userColorHex + ) + } else { + OnboardingView( + isOnboardingComplete: $isOnboardingComplete, + userName: $userName, + userRole: $userRole, + userColorHex: $userColorHex + ) + } + } + .environmentObject(multipeerSession) + } + } +} diff --git a/AtTable/ChatView.swift b/AtTable/ChatView.swift new file mode 100644 index 0000000..7d84ec2 --- /dev/null +++ b/AtTable/ChatView.swift @@ -0,0 +1,323 @@ +import SwiftUI +import MultipeerConnectivity +import Combine + +struct ChatView: View { + @EnvironmentObject var multipeerSession: MultipeerSession + @StateObject var speechRecognizer = SpeechRecognizer() + @ObservedObject var networkMonitor = NetworkMonitor.shared + + let userName: String + let userRole: UserRole + let userColorHex: String + + @AppStorage("isOnboardingComplete") var isOnboardingComplete: Bool = true + @State private var messageText: String = "" + @State private var waitingDots = 0 + let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + // 1. Animated Mesh Background + MeshBackground(colors: [ + Color(hex: userColorHex), + userRole == .deaf ? .purple : .cyan, + .black + ]) + + VStack(spacing: 0) { + // 2. Floating Dynamic Header + HStack { + Button(action: { + speechRecognizer.stopRecording() + multipeerSession.stop() + isOnboardingComplete = false + }) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + Text("Leave") + } + .font(.system(.subheadline, design: .rounded, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .cornerRadius(20) + } + + Spacer() + + VStack(spacing: 2) { + Text("MESH") + .font(.system(size: 10, weight: .black, design: .monospaced)) + .tracking(2) + .foregroundColor(.white.opacity(0.6)) + + Text("\(multipeerSession.connectedPeers.count) Active") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.green) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .cornerRadius(20) + } + .padding(.horizontal) + .padding(.top, 50) // Safe Area + .padding(.bottom, 10) + + // 3. Peer Status / Live Transcriptions + if multipeerSession.connectedPeers.isEmpty { + VStack { + Spacer() + + // Show "Warming up..." on first connection when not on WiFi + if !networkMonitor.isWiFi { + Text("Warming up" + String(repeating: ".", count: waitingDots)) + .font(.title3) + .fontWeight(.medium) + .foregroundColor(.white.opacity(0.7)) + .onReceive(timer) { _ in + waitingDots = (waitingDots + 1) % 4 + } + + Text("No WiFi: connection may take up to 60 seconds") + .font(.caption) + .foregroundColor(.orange) + .padding(.top, 8) + } else { + Text("Searching for others" + String(repeating: ".", count: waitingDots)) + .font(.title3) + .fontWeight(.medium) + .foregroundColor(.white.opacity(0.7)) + .onReceive(timer) { _ in + waitingDots = (waitingDots + 1) % 4 + } + } + + Spacer() + } + } else { + // Connected Peers Row (Small Pills) + if !multipeerSession.connectedPeerUsers.isEmpty { + HStack(spacing: 8) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(multipeerSession.connectedPeerUsers, id: \.self) { peer in + HStack(spacing: 6) { + Circle() + .fill(Color(hex: peer.colorHex)) + .frame(width: 8, height: 8) + Text(peer.name) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .cornerRadius(12) + } + } + .padding(.leading) + } + + // Full Mesh Indicator + if multipeerSession.isAtCapacity { + Text("FULL") + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundColor(.orange) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange.opacity(0.2)) + .cornerRadius(8) + .padding(.trailing) + } + } + .padding(.vertical, 8) + } + + // 4. Message List + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach(multipeerSession.receivedMessages) { message in + MessageBubble( + message: message, + isMyMessage: message.senderNodeID == multipeerSession.myNodeIDPublic + ) + .id(message.id) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + } + .padding(.bottom, 20) + } + .scrollIndicators(.hidden) + .onChange(of: multipeerSession.receivedMessages.count) { + if let lastId = multipeerSession.receivedMessages.last?.id { + withAnimation { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + + if userRole == .deaf { + if let lastMsg = multipeerSession.receivedMessages.last, lastMsg.senderNodeID != multipeerSession.myNodeIDPublic { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } + } + } + // Auto-scroll when transcription card appears to prevent blocking + .onChange(of: multipeerSession.liveTranscripts.isEmpty) { + if !multipeerSession.liveTranscripts.isEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let lastId = multipeerSession.receivedMessages.last?.id { + withAnimation { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + } + } + } + } + } + + // Live Transcription Cards (Moved to Bottom) + // Live Transcription Cards (Moved to Bottom, Stacked Vertically) + if userRole == .deaf && !multipeerSession.liveTranscripts.isEmpty { + VStack(spacing: 8) { + ForEach(multipeerSession.liveTranscripts.sorted(by: { $0.key < $1.key }), id: \.key) { nodeIDKey, text in + // Look up friendly name from connectedPeerUsers by nodeID + let displayName = multipeerSession.connectedPeerUsers.first(where: { $0.nodeID == nodeIDKey })?.name ?? nodeIDKey + VStack(alignment: .leading, spacing: 6) { + Text(displayName) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(Color.cyan) + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading) { + Text(text) + .font(.caption) + .foregroundColor(.white) + .shadow(color: .black, radius: 1) + .multilineTextAlignment(.leading) + .id("text-\(nodeIDKey)") + + Spacer(minLength: 0) + .id("bottom-\(nodeIDKey)") + } + } + .onChange(of: text) { + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo("bottom-\(nodeIDKey)", anchor: .bottom) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity) + .frame(height: 70) // Fixed height for approx 2 lines + .liquidCard() + } + } + .padding(.horizontal) + .padding(.bottom, 10) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // 5. Input Bar + InputBar( + userRole: userRole, + text: $messageText, + isRecording: $speechRecognizer.isRecording, + transcript: speechRecognizer.transcript, + onSend: sendMessage, + onToggleRecording: toggleRecording + ) + } + } + .ignoresSafeArea(.container, edges: .all) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + multipeerSession.start() + + // Re-apply identity if needed (though App state should handle it) + if multipeerSession.userName != userName { + multipeerSession.setIdentity(name: userName, colorHex: userColorHex, role: userRole) + } + + // Configure Speech Recognizer callback + speechRecognizer.onFinalResult = { resultText in + let message = MeshMessage( + id: UUID(), + senderID: userName, + senderNodeID: multipeerSession.myNodeIDPublic, + senderInstance: multipeerSession.myInstancePublic, + senderRole: userRole, + senderColorHex: userColorHex, + content: resultText, + isTranscription: true, + isPartial: false, + timestamp: Date() + ) + multipeerSession.send(message: message) + } + + // Configure Partial Result callback (No Throttling) + speechRecognizer.onPartialResult = { partialText in + let message = MeshMessage( + id: UUID(), + senderID: userName, + senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID for consistent transcript keying + senderInstance: multipeerSession.myInstancePublic, + senderRole: userRole, + senderColorHex: userColorHex, + content: partialText, + isTranscription: true, + isPartial: true, + timestamp: Date() + ) + multipeerSession.send(message: message) + } + + // Auto-start recording for Hearing users + if userRole == .hearing { + speechRecognizer.startRecording() + } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + func sendMessage() { + guard !messageText.isEmpty else { return } + + let message = MeshMessage( + id: UUID(), + senderID: userName, + senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID + senderInstance: multipeerSession.myInstancePublic, + senderRole: userRole, + senderColorHex: userColorHex, + content: messageText, + isTranscription: false, + isPartial: false, + timestamp: Date() + ) + + multipeerSession.send(message: message) + messageText = "" + } + + func toggleRecording() { + if speechRecognizer.isRecording { + speechRecognizer.stopRecording() + } else { + speechRecognizer.startRecording() + } + } +} diff --git a/AtTable/Constants.swift b/AtTable/Constants.swift new file mode 100644 index 0000000..67d22b3 --- /dev/null +++ b/AtTable/Constants.swift @@ -0,0 +1,5 @@ +import Foundation + +struct Constants { + static let serviceType = "access-mesh" // Must match Info.plist _access-mesh._tcp +} diff --git a/AtTable/DesignSystem.swift b/AtTable/DesignSystem.swift new file mode 100644 index 0000000..027628a --- /dev/null +++ b/AtTable/DesignSystem.swift @@ -0,0 +1,216 @@ +import SwiftUI + +// MARK: - Colors & Gradients +struct DesignSystem { + struct Colors { + static let deepSpace = Color(hex: "0a0a12") + static let neonBlue = Color(hex: "00f2ff") + static let neonPurple = Color(hex: "bd00ff") + static let neonTeal = Color(hex: "00ff9d") + + static func roleGradient(for role: UserRole) -> LinearGradient { + switch role { + case .deaf: + return LinearGradient( + gradient: Gradient(colors: [Color(hex: "43cea2"), Color(hex: "185a9d")]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + case .hearing: + return LinearGradient( + gradient: Gradient(colors: [Color(hex: "FDB99B"), Color(hex: "CF8BF3"), Color(hex: "A770EF")]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + } + + struct Typography { + static func title(_ text: String) -> Text { + Text(text) + .font(.system(.largeTitle, design: .rounded)) + .fontWeight(.heavy) + } + } +} + +// MARK: - Modifiers + +struct LiquidGlassCard: ViewModifier { + func body(content: Content) -> some View { + content + .background(.ultraThinMaterial) + .cornerRadius(24) + .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(.white.opacity(0.2), lineWidth: 1) + ) + } +} + +struct GlowingEdge: ViewModifier { + var color: Color + func body(content: Content) -> some View { + content + .shadow(color: color.opacity(0.6), radius: 20, x: 0, y: 0) + .shadow(color: color.opacity(0.3), radius: 40, x: 0, y: 0) + } +} + +// MARK: - Views + +struct MeshBackground: View { + @State private var start = UnitPoint(x: 0, y: -2) + @State private var end = UnitPoint(x: 4, y: 0) + + // Dynamic Orb States + @State private var orb1Offset = CGSize.zero + @State private var orb2Offset = CGSize.zero + @State private var orb3Offset = CGSize.zero // Pulsing Blue Orb + + @State private var orb1Scale: CGFloat = 1.0 + // Orb 2 Shape Shifting (Independent Scales) + @State private var orb2ScaleX: CGFloat = 1.0 + @State private var orb2ScaleY: CGFloat = 1.0 + + @State private var orb3Scale: CGFloat = 1.0 // Pulsing Blue Orb + @State private var orb3Opacity: Double = 0.3 // Pulsing Opacity + + let colors: [Color] + + var body: some View { + GeometryReader { proxy in + ZStack { + Color.black.ignoresSafeArea() + + // 1. Moving Gradient Background + LinearGradient(gradient: Gradient(colors: colors), startPoint: start, endPoint: end) + .opacity(0.4) + .ignoresSafeArea() + .blur(radius: 100) + .onAppear { + withAnimation(.easeInOut(duration: 10).repeatForever(autoreverses: true)) { + start = UnitPoint(x: 4, y: 0) + end = UnitPoint(x: 0, y: 2) + } + } + + // 2. Floating Orbs for Depth + + // Orb 3: Pulsing Blue Orb (Lightens/Darkens) + Circle() + .fill(Color.blue) + .frame(width: proxy.size.width * 1.0, height: proxy.size.width * 1.0) + .blur(radius: 150) // Highly blurred + .opacity(orb3Opacity) // Pulsing Opacity + .scaleEffect(orb3Scale) + .offset(orb3Offset) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + + // Orb 1 + Circle() + .fill(colors.first ?? .blue) + .frame(width: proxy.size.width * 0.9, height: proxy.size.width * 0.9) + .blur(radius: 120) + .scaleEffect(orb1Scale) + .offset(orb1Offset) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) // Center start + + // Orb 2 (Black Orb) - Shape Shifting + Circle() + .fill(colors.last ?? .purple) + .frame(width: proxy.size.width * 0.8, height: proxy.size.width * 0.8) + .blur(radius: 120) + .scaleEffect(x: orb2ScaleX, y: orb2ScaleY) // Independent scaling creates ovals + .offset(orb2Offset) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) // Center start + } + .onAppear { + animateOrb1(in: proxy.size) + animateOrb2(in: proxy.size) + animateOrb3(in: proxy.size) + } + } + .ignoresSafeArea() + } + + private func animateOrb1(in size: CGSize) { + // Random wandering loop + let duration = Double.random(in: 6...9) + withAnimation(.easeInOut(duration: duration)) { + orb1Offset = CGSize( + width: CGFloat.random(in: -size.width/2...size.width/2), + height: CGFloat.random(in: -size.height/2...size.height/2) + ) + orb1Scale = CGFloat.random(in: 0.9...1.2) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + animateOrb1(in: size) + } + } + + private func animateOrb2(in size: CGSize) { + // Random wandering loop with Shape Shifting + let duration = Double.random(in: 7...10) + withAnimation(.easeInOut(duration: duration)) { + orb2Offset = CGSize( + width: CGFloat.random(in: -size.width/2...size.width/2), + height: CGFloat.random(in: -size.height/2...size.height/2) + ) + // Independent X and Y scales morph the shape between circle and oval + orb2ScaleX = CGFloat.random(in: 0.4...1.1) + orb2ScaleY = CGFloat.random(in: 0.4...1.1) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + animateOrb2(in: size) + } + } + + private func animateOrb3(in size: CGSize) { + // Pulsing Blue Orb Animation + let duration = Double.random(in: 10...15) + withAnimation(.easeInOut(duration: duration)) { + orb3Offset = CGSize( + width: CGFloat.random(in: -size.width/3...size.width/3), + height: CGFloat.random(in: -size.height/3...size.height/3) + ) + orb3Scale = CGFloat.random(in: 0.9...1.1) + orb3Opacity = Double.random(in: 0.1...0.6) // Lightens and darkens + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + animateOrb3(in: size) + } + } +} + +extension Color { + var complementary: Color { + let uiColor = UIColor(self) + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + uiColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + + let newHue = h + 0.5 + let finalHue = newHue > 1.0 ? newHue - 1.0 : newHue + + return Color(hue: finalHue, saturation: s, brightness: b, opacity: a) + } +} + +extension View { + func liquidCard() -> some View { + self.modifier(LiquidGlassCard()) + } + + func glowingEdge(color: Color) -> some View { + self.modifier(GlowingEdge(color: color)) + } +} diff --git a/AtTable/InputBar.swift b/AtTable/InputBar.swift new file mode 100644 index 0000000..6fbf880 --- /dev/null +++ b/AtTable/InputBar.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct InputBar: View { + let userRole: UserRole + @Binding var text: String + @Binding var isRecording: Bool + var transcript: String // New property for live transcript + var onSend: () -> Void + var onToggleRecording: () -> Void + + @State private var orbScale: CGFloat = 1.0 + + var body: some View { + VStack(spacing: 0) { + // Gradient Divider + LinearGradient( + colors: [.clear, .white.opacity(0.2), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + + if userRole == .deaf { + deafInputView + } else { + hearingInputView + } + } + .frame(height: userRole == .hearing ? 200 : 160) // Increased height for transcript + } + } + + var deafInputView: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + TextField("Type a message...", text: $text) + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(24) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(.white.opacity(0.2), lineWidth: 1) + ) + .foregroundColor(.white) + .submitLabel(.send) + .onSubmit { + if !text.isEmpty { + onSend() + } + } + + Button(action: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + onSend() + }) { + Image(systemName: "arrow.up") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.black) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(text.isEmpty ? Color.gray : Color.white) + ) + .shadow(radius: 5) + } + .disabled(text.isEmpty) + } + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(["Yes", "No", "Hold on", "Thanks", "Please repeat"], id: \.self) { phrase in + Button(action: { + text = phrase + onSend() + }) { + Text(phrase) + .font(.caption) + .fontWeight(.bold) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.white.opacity(0.1)) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.3), lineWidth: 1) + ) + .foregroundColor(.white) + } + } + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + + var hearingInputView: some View { + VStack(spacing: 16) { + // Live Transcription Box + if !transcript.isEmpty { + ScrollViewReader { proxy in + ScrollView { + Text(transcript) + .font(.system(.body, design: .rounded)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) // Leading alignment better for scrolling reading + .padding() + .id("transcriptionText") + } + .frame(height: 60) // Approx 2 lines + padding + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.2)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + .onChange(of: transcript) { + withAnimation { + proxy.scrollTo("transcriptionText", anchor: .bottom) + } + } + } + .padding(.horizontal) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + Text(isRecording ? "Listening..." : "Press to speak...") + .frame(height: 60) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + .font(.system(.body, design: .rounded)) + .foregroundColor(.white.opacity(0.5)) + .padding(.horizontal) + } + + HStack { + Spacer() + + // Glowing Intelligence Orb + Button(action: onToggleRecording) { + ZStack { + // Outer Glow + Circle() + .fill( + AngularGradient( + gradient: Gradient(colors: [.blue, .purple, .cyan, .blue]), + center: .center + ) + ) + .frame(width: 80, height: 80) + .blur(radius: isRecording ? 10 : 0) + .rotationEffect(.degrees(isRecording ? 360 : 0)) + .animation(isRecording ? Animation.linear(duration: 2).repeatForever(autoreverses: false) : .default, value: isRecording) + + // Core + Circle() + .fill(.ultraThinMaterial) + .frame(width: 76, height: 76) + .overlay( + Image(systemName: isRecording ? "stop.fill" : "mic.fill") + .font(.title) + .foregroundColor(.white) + .shadow(color: .white, radius: 5) + ) + } + } + .scaleEffect(isRecording ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.5), value: isRecording) + + Spacer() + } + } + .padding(.bottom, 20) + .padding(.top, 10) + } +} diff --git a/AtTable/MeshMessage.swift b/AtTable/MeshMessage.swift new file mode 100644 index 0000000..ba7a7bc --- /dev/null +++ b/AtTable/MeshMessage.swift @@ -0,0 +1,17 @@ +import Foundation + +struct MeshMessage: Codable, Identifiable, Hashable { + var id: UUID + var senderID: String // MCPeerID.displayName (for display purposes) + var senderNodeID: String = "" // Stable node ID per install + var senderInstance: Int = 0 // Monotonic instance ID to detect ghosts/restarts + var senderRole: UserRole + var senderColorHex: String + var content: String + var isTranscription: Bool // True = Audio source, False = Typed source + var isPartial: Bool = false // True = Live partial result, False = Final confirmed result + var isHandshake: Bool = false // True = Handshake message with user details + var isKeepAlive: Bool = false // True = Heartbeat to maintain AWDL links (ignore in UI) + var connectedNodeIDs: [String]? = nil // Gossip: List of NodeIDs this peer is connected to (for clique repair) + var timestamp: Date +} diff --git a/AtTable/MessageBubble.swift b/AtTable/MessageBubble.swift new file mode 100644 index 0000000..b54d282 --- /dev/null +++ b/AtTable/MessageBubble.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct MessageBubble: View { + let message: MeshMessage + let isMyMessage: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + if isMyMessage { Spacer() } + + if !isMyMessage { + // Avatar Placeholder + Circle() + .fill(Color(hex: message.senderColorHex)) + .frame(width: 30, height: 30) + .shadow(color: Color(hex: message.senderColorHex).opacity(0.5), radius: 5) + } + + VStack(alignment: isMyMessage ? .trailing : .leading, spacing: 4) { + if !isMyMessage { + Text(message.senderID) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white.opacity(0.7)) + } + + HStack(alignment: .top) { + if message.isTranscription { + Image(systemName: "waveform") + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + } + + Text(message.content) + .font(.system(.body, design: .rounded)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .background( + ZStack { + if isMyMessage { + LinearGradient( + colors: [Color.blue.opacity(0.8), Color.purple.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + Color(hex: message.senderColorHex).opacity(0.3) + } + } + ) + .background(.ultraThinMaterial) + .cornerRadius(18, corners: isMyMessage ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight]) + .shadow(color: isMyMessage ? .blue.opacity(0.3) : Color(hex: message.senderColorHex).opacity(0.2), radius: 5, x: 0, y: 2) + .overlay( + RoundedCorner(radius: 18, corners: isMyMessage ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight]) + .stroke(.white.opacity(0.2), lineWidth: 1) + ) + } + + if !isMyMessage { Spacer() } + } + .padding(.horizontal) + .padding(.vertical, 4) + } +} + +// Rounded Corner Shape for Bubble +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} diff --git a/AtTable/MultipeerSession.swift b/AtTable/MultipeerSession.swift new file mode 100644 index 0000000..ea36bce --- /dev/null +++ b/AtTable/MultipeerSession.swift @@ -0,0 +1,1191 @@ +import MultipeerConnectivity +import SwiftUI +import Combine +import os + +class MultipeerSession: NSObject, ObservableObject { + private let serviceType = Constants.serviceType + + // Services + private var myPeerId: MCPeerID? // Made optional to prevent cleanup/restart crashes + private var serviceAdvertiser: MCNearbyServiceAdvertiser! + private var serviceBrowser: MCNearbyServiceBrowser! + private var session: MCSession? // Already optional? No, it was ! before. Let's check. + private var myDiscoveryInfo: [String: String]? // Store my info for tie-breaking + private var isRestarting = false // Debounce flag + private var isStarted = false // Debounce flag to prevent duplicate start() calls + private var delayedRestartItem: DispatchWorkItem? // Item for delayed recovery logic + private var keepAliveTimer: Timer? // Heartbeat to maintain AWDL links + private let log = Logger() + + // Node Identity (stable per install + monotonic instance per session) + private var myNodeID: String = "" + private var myInstance: Int = 0 + + // Per-peer state for robust mesh handling (keyed by nodeID) + private var latestByNodeID: [String: (peerID: MCPeerID, instance: Int)] = [:] + private var pendingInvites: [String: MCPeerID] = [:] // nodeID -> peerID for active invites (enables cancellation) + private var cooldownUntil: [String: Date] = [:] // nodeIDs to temporarily ignore + private var connectingTimeoutTimers: [MCPeerID: DispatchWorkItem] = [:] // Per-peer watchdogs + private var stabilityTimers: [String: Timer] = [:] // nodeID -> Timer (15s reset delay) + private var peerNodeIDMap: [MCPeerID: String] = [:] // Map MCPeerID -> nodeID for lookups + + // Poisoned state detection + private var consecutiveFailures: [String: Int] = [:] // Track failures per nodeID + private static let poisonedThreshold = 5 // N consecutive failures = poisoned (higher for AWDL flakiness) + + // State + @Published var connectedPeers: [MCPeerID] = [] + @Published var connectedPeerUsers: [PeerUser] = [] + @Published var receivedMessages: [MeshMessage] = [] + @Published var liveTranscripts: [String: String] = [:] // Keyed by senderNodeID for reliable identity + + // Mesh Capacity Limit (MPC is unstable beyond 7-8 peers) + static let maxPeers = 7 + var isAtCapacity: Bool { connectedPeers.count >= Self.maxPeers } + + // Expose nodeID for UI to determine message ownership + var myNodeIDPublic: String { myNodeID } + var myInstancePublic: Int { myInstance } + + // User Details Wrappers + var userName: String { myUserName } + var userColor: String { myUserColor } + var userRole: UserRole { myUserRole } + + // User Details + private var myUserName: String = UIDevice.current.name + private var myUserColor: String = "#3357FF" + private var myUserRole: UserRole = .hearing + + // Dynamic Identity Update (since Session persists longer than Onboarding) + func setIdentity(name: String, colorHex: String, role: UserRole) { + self.myUserName = name + self.myUserColor = colorHex + self.myUserRole = role + // Note: Changing identity doesn't restart services automatically + // but new connections will see updated info. + // For deeper updates, we'd need to re-create peerID, but simpler is better here. + } + + override init() { + super.init() + ensureIdentity() + // Services now started manually via start() to support app-level lifecycle + } + + // MARK: - Lifecycle Control + + func start() { + // Ensure session is set up (fresh session on every start) + if session == nil { + setupSession() + } + startServices() + } + + func stop() { + // Reuse the robust disconnect logic to ensure all state/UI is cleared + disconnect() + } + + + + private func ensureIdentity() { + // Generate/retrieve stable identity ONCE per app launch + // OR recreate if myPeerId was wiped (e.g. after disconnect/leave) + if myNodeID.isEmpty { + myNodeID = NodeIdentity.nodeID + myInstance = NodeIdentity.nextInstance() + + // Create fresh ID (but keep displayName stable for this app run if possible) + let peerID = MCPeerID(displayName: myUserName) + self.myPeerId = peerID + } else if myPeerId == nil { + // RECOVERY: NodeID exists but PeerID is gone (disconnect called). Recreate it. + log.info("Identity Recovery: Recreating MCPeerID for existing NodeID \(self.myNodeID)") + let peerID = MCPeerID(displayName: myUserName) + self.myPeerId = peerID + } + } + + private func setupSession() { + guard let peerID = self.myPeerId else { + log.error("Attempted to setup session without identity!") + ensureIdentity() + // CRITICAL FIX: After identity recovery, we MUST complete the setup. + // Recursively call setupSession now that myPeerId exists. + setupSession() + return + } + + // Create fresh Session + // Encryption .none is recommended for reliable AWDL (mixed network) connections to avoid handshake timeouts + session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .none) + session?.delegate = self + + // Create fresh Advertiser & Browser with STABLE NODE IDENTITY + // nodeID is stable per install, instance increments per session start + // This allows other devices to reliably identify us and filter ghost peers + let discoveryInfo: [String: String] = [ + "nodeID": myNodeID, + "instance": String(myInstance), + "ts": String(Date().timeIntervalSince1970) + ] + self.myDiscoveryInfo = discoveryInfo + + serviceAdvertiser = MCNearbyServiceAdvertiser( + peer: peerID, + discoveryInfo: discoveryInfo, + serviceType: serviceType + ) + serviceAdvertiser.delegate = self + + serviceBrowser = MCNearbyServiceBrowser(peer: peerID, serviceType: serviceType) + serviceBrowser.delegate = self + + log.info("Session setup complete with nodeID: \(self.myNodeID) instance: \(self.myInstance) peerID: \(peerID.displayName)") + } + + private func startServices() { + guard serviceAdvertiser != nil, serviceBrowser != nil else { return } + serviceBrowser.startBrowsingForPeers() + + // Delay advertising to ensure MCSession listener is ready + // Reduced to 0.5s (was 2.0s) - 2.0s was causing "Connection Refused" because + // peers tried to connect before we were ready. 0.5s is sufficient for cleanup. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + self.serviceAdvertiser?.startAdvertisingPeer() + self.log.info("Advertising started (delayed 0.5s for listener warmup)") + } + } + + private func stopServices() { + serviceAdvertiser?.stopAdvertisingPeer() + serviceBrowser?.stopBrowsingForPeers() + } + + // Helper to refresh service state without killing connections + // We restart BOTH Advertiser and Browser to flush stale routes/caches in the OS/AWDL stack + private func restartServices(forcePoisonedRecovery: Bool = false) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Debounce: If already restarting, ignore this request + guard !self.isRestarting else { + self.log.info("Services already restarting, skipping request.") + return + } + + // Cancel any pending delayed restart since we are restarting now + self.delayedRestartItem?.cancel() + self.delayedRestartItem = nil + + // Cancel all per-peer timers + for (_, timer) in self.connectingTimeoutTimers { + timer.cancel() + } + self.connectingTimeoutTimers.removeAll() + + // Stop keep-alive to prevent multiple timers if restart happens while peers present + self.stopKeepAlive() + + self.isRestarting = true + + // CLEAR PER-PEER STATE: Remove stale peer info from previous sessions + self.latestByNodeID.removeAll() + self.pendingInvites.removeAll() + self.peerNodeIDMap.removeAll() + // Keep cooldownUntil - we want to remember temporarily-banned peers + // Keep consecutiveFailures - we need this history for poisoned state detection + + self.log.info("Restarting services to flush stale peers/routes...") + self.serviceBrowser?.stopBrowsingForPeers() + self.serviceAdvertiser?.stopAdvertisingPeer() + + // Add RANDOM JITTER to break synchronized restart loops + let jitter = Double.random(in: 0.0...1.0) + // OPTIMIZATION: Faster restart on Wi-Fi (1.0s base) vs AWDL (1.0s base) + // Increased to 1.0s to ensure socket cleanup and prevent "Connection refused" loops + let baseDelay = 1.0 + let totalDelay = baseDelay + jitter + self.log.info("Restart delay: \(String(format: "%.2f", totalDelay))s (with jitter)") + + DispatchQueue.main.asyncAfter(deadline: .now() + totalDelay) { [weak self] in + guard let self = self else { return } + + // Clear delegate BEFORE disconnecting to prevent zombie callbacks + self.session?.delegate = nil + self.session?.disconnect() + self.session = nil + + // POISONED STATE: Only rebuild MCPeerID if in poisoned state + // Otherwise keep same PeerID to reduce ghost artifacts on other devices + if forcePoisonedRecovery { + self.log.info("POISONED STATE detected. Full reset with new MCPeerID.") + self.consecutiveFailures.removeAll() // Reset failure tracking + self.cooldownUntil.removeAll() // Reset cooldowns + self.setupSession() // New MCPeerID + new instance + } else { + // Keep MCPeerID stable, just increment instance and rebuild session + self.myInstance = NodeIdentity.nextInstance() + + let discoveryInfo: [String: String] = [ + "nodeID": self.myNodeID, + "instance": String(self.myInstance), + "ts": String(Date().timeIntervalSince1970) + ] + self.myDiscoveryInfo = discoveryInfo + + // Recreate session with same PeerID + guard let peerID = self.myPeerId else { + self.log.error("Restart failed: myPeerId is nil. Re-running setup.") + self.setupSession() // Fallback to full setup + return + } + + self.session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .none) + self.session?.delegate = self + + // Recreate advertiser/browser with new discovery info + self.serviceAdvertiser = MCNearbyServiceAdvertiser( + peer: peerID, + discoveryInfo: discoveryInfo, + serviceType: self.serviceType + ) + self.serviceAdvertiser.delegate = self + + self.serviceBrowser = MCNearbyServiceBrowser(peer: peerID, serviceType: self.serviceType) + self.serviceBrowser.delegate = self + + self.log.info("Services rebuilt with same PeerID, new instance: \(self.myInstance)") + } + + self.serviceBrowser?.startBrowsingForPeers() + + // 0.5s delay to ensure old listener is fully unbound + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + guard self.isRestarting else { return } // Abort if cancelled + + self.serviceAdvertiser?.startAdvertisingPeer() + self.log.info("Advertising restarted (delayed 0.5s)") + + self.isRestarting = false + } + } + } + } + + func updateMyDetails(name: String, color: String, role: UserRole) { + self.myUserName = name + self.myUserColor = color + self.myUserRole = role + + // If connected, resend handshake with new details + if let session = session, !session.connectedPeers.isEmpty { + sendHandshake() + } + } + + func disconnect() { + // 1. Cancel any pending recovery task immediately + self.delayedRestartItem?.cancel() + self.delayedRestartItem = nil + + // 2. Cancel all per-peer timers to prevent zombie callbacks + for (_, timer) in connectingTimeoutTimers { + timer.cancel() + } + connectingTimeoutTimers.removeAll() + + // Stop Keep-Alive heartbeat + stopKeepAlive() + + stopServices() + serviceAdvertiser?.delegate = nil + serviceBrowser?.delegate = nil + session?.delegate = nil + session?.disconnect() + + // Clear references + serviceAdvertiser = nil + serviceBrowser = nil + session = nil + myPeerId = nil + + // 3. Clear per-peer state + pendingInvites.removeAll() + latestByNodeID.removeAll() + peerNodeIDMap.removeAll() + cooldownUntil.removeAll() + consecutiveFailures.removeAll() + + // 4. Reset flags + isStarted = false + isRestarting = false + log.info("Services stopped, all timers cancelled, per-peer state cleared.") + + DispatchQueue.main.async { + self.connectedPeers.removeAll() + self.connectedPeerUsers.removeAll() + self.receivedMessages.removeAll() + self.liveTranscripts.removeAll() + } + } + + func send(message: MeshMessage) { + guard let session = session, !session.connectedPeers.isEmpty else { return } + + // Enforce senderNodeID on all outgoing messages + var msg = message + if msg.senderNodeID.isEmpty { + msg.senderNodeID = myNodeID + } + + do { + let data = try JSONEncoder().encode(msg) + // Use unreliable delivery for partials to reduce overhead/latency + let mode: MCSessionSendDataMode = msg.isPartial ? .unreliable : .reliable + try session.send(data, toPeers: session.connectedPeers, with: mode) + + // Handle local state updates + DispatchQueue.main.async { + if msg.isHandshake { + // Don't show handshake in messages + } else if msg.isPartial { + // For partials, we don't usually show our own in the live row + } else if msg.isKeepAlive { + // Don't show keep-alive heartbeats in messages + } else { + self.receivedMessages.append(msg) + } + } + } catch { + log.error("Error sending message: \(error.localizedDescription)") + } + } + + private func sendHandshake() { + let message = MeshMessage( + id: UUID(), + senderID: myUserName, + senderNodeID: myNodeID, + senderInstance: myInstance, // CRITICAL: Authoritative instance for ghost filtering + senderRole: myUserRole, + senderColorHex: myUserColor, + content: "Handshake", + isTranscription: false, + isPartial: false, + isHandshake: true, + timestamp: Date() + ) + send(message: message) + } + + // MARK: - Keep-Alive (Maintain AWDL Links) + + private func startKeepAlive() { + stopKeepAlive() // Clear any existing timer + keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.sendKeepAlive() + } + log.info("Keep-Alive timer started (10s interval).") + } + + private func stopKeepAlive() { + keepAliveTimer?.invalidate() + keepAliveTimer = nil + } + + private func sendKeepAlive() { + guard let session = session, !session.connectedPeers.isEmpty else { return } + let message = MeshMessage( + id: UUID(), + senderID: myUserName, + senderNodeID: myNodeID, + senderRole: myUserRole, + senderColorHex: myUserColor, + content: "💓", + isTranscription: false, + isPartial: false, + isHandshake: false, + isKeepAlive: true, + connectedNodeIDs: self.connectedPeerUsers.map { $0.nodeID }.filter { !$0.isEmpty }, // Gossip: Share who we know + timestamp: Date() + ) + send(message: message) + } + + // MARK: - Helper Methods + + /// Calculate exponential backoff for a peer based on failure count + /// 3s → 6s → 12s → max 30s + private func calculateBackoff(failures: Int) -> TimeInterval { + // Base = 0.5s (was 2.0s). Max = 30s. + // 1: 0.5s + // 2: 1.0s + // 3: 2.0s + // 4: 4.0s + // ... + let baseDelay: TimeInterval = 0.5 + let exponential = pow(2.0, Double(failures - 1)) + let delay = baseDelay * exponential + return min(delay, 30.0) + } + + private func createInviteContext() -> Data? { + return try? JSONEncoder().encode([ + "nodeID": self.myNodeID, + "instance": String(self.myInstance) + ]) + } + + /// Schedule a restart only if the mesh stays empty for 2 seconds + private func scheduleRestartIfStillEmpty() { + delayedRestartItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard self.connectedPeers.isEmpty else { + self.log.info("Skipping restart - we now have connections.") + return + } + // NO-OP: We disabled the aggressive restart because it causes infinite loops ("Death Spiral") + // when one peer is stuck in a bad state. It causes Instance ID cycling which confuses other peers. + // We now rely purely on "Smart Retry" to reconnect us. + self.log.info("Mesh still empty. Skipping delayed restart to preserve stability (Smart Retry handles reconnection).") + // self.restartServices() // DISABLED + } + delayedRestartItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: item) + } + + deinit { + disconnect() + } +} + +extension MultipeerSession: MCNearbyServiceAdvertiserDelegate { + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) { + log.error("ServiceAdvertiser didNotStartAdvertisingPeer: \(error.localizedDescription)") + } + + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // IDENTITY CHECK: Ignore stale advertisers + guard advertiser == self.serviceAdvertiser else { return } + + self.log.info("didReceiveInvitationFromPeer \(peerID)") + + // Extract nodeID from invitation context (if provided by inviter) + if let context = context, + let contextDict = try? JSONDecoder().decode([String: String].self, from: context), + let inviterNodeID = contextDict["nodeID"], + let inviterInstanceStr = contextDict["instance"], + let inviterInstance = Int(inviterInstanceStr) { + // Map peerID -> nodeID immediately + self.peerNodeIDMap[peerID] = inviterNodeID + + // Ghost filtering: reject invitations from older instances + if let existing = self.latestByNodeID[inviterNodeID] { + if inviterInstance > existing.instance { + self.latestByNodeID[inviterNodeID] = (peerID, inviterInstance) + self.log.info("Invitation context: updated to newer instance \(inviterInstance) for \(inviterNodeID)") + } else if inviterInstance < existing.instance { + // REJECT GHOST INVITATION: We've seen a newer instance via discovery + self.log.info("Rejecting GHOST invitation from \(inviterNodeID) instance \(inviterInstance) (we know instance \(existing.instance))") + invitationHandler(false, nil) + return + } else { + // Same instance - could be same peer or MCPeerID mismatch, accept it + self.log.info("Invitation from same instance \(inviterInstance) for \(inviterNodeID)") + } + } else { + self.latestByNodeID[inviterNodeID] = (peerID, inviterInstance) + self.log.info("Extracted inviter nodeID: \(inviterNodeID) instance: \(inviterInstance)") + } + } + + // DEADLOCK FIX: If they are inviting us, they think we are NOT connected. + // If we think we ARE connected, we are wrong (stale/half-open). + // We must Disconnect our stale socket and Accept the new invite. + if self.session?.connectedPeers.contains(peerID) == true { + self.log.warning("Received invitation from 'connected' peer \(peerID). Assuming Half-Open Deadlock. Resetting connection...") + // Kill the stale connection + self.session?.cancelConnectPeer(peerID) + + // DELAY ACCEPTANCE to allow disconnect to propagate and clear state (0.5s) + // This prevents "Connection refused" or immediate closure of the new socket + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + guard self.session != nil else { return } + self.log.info("Accepting invitation after Half-Open reset delay.") + invitationHandler(true, self.session) + } + return + } + + // CAPACITY CHECK: Reject new peers if mesh is at capacity + guard !self.isAtCapacity else { + self.log.info("Mesh at capacity (\(Self.maxPeers) peers). Rejecting invitation from \(peerID).") + invitationHandler(false, nil) + return + } + + // IMMEDIATE ACCEPT: Per stability protocol, accept with 0.0s delay. + guard self.session != nil else { return } + invitationHandler(true, self.session) + } + } +} + +extension MultipeerSession: MCNearbyServiceBrowserDelegate { + func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) { + log.error("ServiceBrowser didNotStartBrowsingForPeers: \(error.localizedDescription)") + } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // IDENTITY CHECK: Ignore stale browsers + guard browser == self.serviceBrowser else { return } + + self.log.info("ServiceBrowser foundPeer: \(peerID)") + + guard let session = self.session else { return } + + // If already connected, ignore + if session.connectedPeers.contains(peerID) { + return + } + + // REQUIRE nodeID in discovery info (no backward compatibility needed) + guard let theirNodeID = info?["nodeID"], + let theirInstanceStr = info?["instance"], + let theirInstance = Int(theirInstanceStr) else { + self.log.info("Ignoring peer without nodeID/instance: \(peerID)") + return + } + + self.log.info("Found peer \(peerID) nodeID: \(theirNodeID) instance: \(theirInstance)") + + // Store nodeID -> peerID mapping for later lookups + self.peerNodeIDMap[peerID] = theirNodeID + + // 1. GHOST FILTERING: Keep only the highest instance for each nodeID + // Do this FIRST so we can clear cooldown on restart + if let existing = self.latestByNodeID[theirNodeID] { + if theirInstance > existing.instance { + // GHOST BUSTERS: Aggressively kill connection to the old stale peerID + self.session?.cancelConnectPeer(existing.peerID) + self.log.info("Found NEWER instance for nodeID \(theirNodeID): \(theirInstance) > \(existing.instance). Cancelled connection to ghost \(existing.peerID).") + + self.latestByNodeID[theirNodeID] = (peerID, theirInstance) + + // CLEAR COOLDOWN: Peer restarted, give them another chance + if self.cooldownUntil[theirNodeID] != nil { + self.cooldownUntil.removeValue(forKey: theirNodeID) + self.consecutiveFailures.removeValue(forKey: theirNodeID) + self.log.info("Cleared cooldown for \(theirNodeID) due to new instance") + } + + // CLEAR PENDING INVITE: If we were inviting the old instance, cancel it so we can invite the new one + if let pendingPeerID = self.pendingInvites[theirNodeID] { + self.log.info("Cancelling stale pending invite to \(pendingPeerID) for newer instance") + // Note: We can't explicitly 'cancel' an invite in MPC, but we remove the tracking so we can send a new one + self.pendingInvites.removeValue(forKey: theirNodeID) + + // If we had a watchdog timer for the old peer, cancel it + self.connectingTimeoutTimers[pendingPeerID]?.cancel() + self.connectingTimeoutTimers.removeValue(forKey: pendingPeerID) + } + } else if theirInstance < existing.instance { + self.log.info("Ignoring OLDER instance for nodeID \(theirNodeID): \(theirInstance) < \(existing.instance)") + return // Ignore ghost peer + } else if existing.peerID != peerID { + // Same instance but different MCPeerID - likely a race condition, keep existing + self.log.info("Ignoring duplicate instance for nodeID \(theirNodeID)") + return + } + } else { + self.latestByNodeID[theirNodeID] = (peerID, theirInstance) + } + + // 2. COOLDOWN CHECK: Ignore recently-failed peers (after ghost filtering) + if let cooldownEnd = self.cooldownUntil[theirNodeID], Date() < cooldownEnd { + self.log.info("Ignoring peer \(theirNodeID) - still in cooldown until \(cooldownEnd)") + return + } + + // 3. DETERMINISTIC LEADER/FOLLOWER: Use nodeID comparison (simple and reliable) + var shouldInvite = false + + if self.myNodeID > theirNodeID { + shouldInvite = true + self.log.info("Decision: I am LEADER (myNodeID > theirNodeID). I will invite.") + } else if self.myNodeID < theirNodeID { + shouldInvite = false + self.log.info("Decision: I am FOLLOWER (myNodeID < theirNodeID). I will wait.") + } else { + // Same nodeID (shouldn't happen - same device) - compare instance + shouldInvite = self.myInstance > theirInstance + self.log.info("Decision: Same nodeID, comparing instance (\(self.myInstance) vs \(theirInstance)).") + } + + if shouldInvite { + // CAPACITY CHECK: Skip invite if mesh is at capacity + guard !self.isAtCapacity else { + self.log.info("Mesh at capacity (\(Self.maxPeers) peers). Skipping invite to \(peerID).") + return + } + + guard !self.isRestarting else { return } + + // PENDING INVITE CHECK: Don't double-invite + guard self.pendingInvites[theirNodeID] == nil else { + self.log.info("Already have pending invite for nodeID \(theirNodeID). Skipping.") + return + } + + // GHOST CHECK: Ensure this is still the latest peer for this nodeID + if let latest = self.latestByNodeID[theirNodeID], + latest.peerID != peerID { + self.log.info("Aborting invite to \(peerID). Found newer MCPeerID for nodeID \(theirNodeID)!") + return + } + + // Track pending invite with peerID for cancellation + self.pendingInvites[theirNodeID] = peerID + + // Include nodeID/instance in invitation context so receiver can map immediately + let contextData = try? JSONEncoder().encode([ + "nodeID": self.myNodeID, + "instance": String(self.myInstance) + ]) + + // WARMUP DELAY: Wait 2.0s before inviting to allow listener to spin up + // This prevents "Connection refused" if we discover them immediately after they restart + self.log.info("Inviting peer \(peerID) (nodeID: \(theirNodeID)) in 1.0s (listener warmup).") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self else { return } + + // VALIDATION CHECKS after delay + guard browser == self.serviceBrowser else { + self.log.info("Aborting delayed invite to \(peerID). Browser mismatch (restart occurred?).") + return + } + guard let session = self.session else { return } + + // 1. Is peer still pending? (Might have been cancelled by ghost check) + guard self.pendingInvites[theirNodeID] == peerID else { + self.log.info("Aborting delayed invite to \(peerID). No longer pending.") + return + } + + // 2. Are we already connected? + if session.connectedPeers.contains(peerID) { + return + } + + browser.invitePeer(peerID, to: session, withContext: contextData, timeout: 10) // Increased timeout slightly + } + } + } + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + DispatchQueue.main.async { [weak self] in + guard let self = self, browser == self.serviceBrowser else { return } + self.log.info("ServiceBrowser lostPeer: \(peerID)") + + // Clean up per-peer state for lost peer + if let nodeID = self.peerNodeIDMap[peerID] { + // Remove pending invite if it was for this peer + if self.pendingInvites[nodeID] == peerID { + self.pendingInvites.removeValue(forKey: nodeID) + self.log.info("Cleared pending invite for nodeID \(nodeID)") + } + + // Clear latestByNodeID if it points to this peerID + if let latest = self.latestByNodeID[nodeID], latest.peerID == peerID { + self.latestByNodeID.removeValue(forKey: nodeID) + self.log.info("Cleared latestByNodeID for nodeID \(nodeID)") + } + } + + // Remove peerNodeIDMap entry + self.peerNodeIDMap.removeValue(forKey: peerID) + + + } + } +} + +extension MultipeerSession: MCSessionDelegate { + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // DEBUG: Raw session logging to catch Zombies + let sessionStatus = (session == self.session) ? "CURRENT" : "ZOMBIE" + self.log.debug("RAW didChange: \(peerID) -> \(state.rawValue) on \(sessionStatus) session") + + // IDENTITY CHECK: Ignore stale sessions + guard session == self.session else { return } + + // Look up nodeID for this peer + let nodeID = self.peerNodeIDMap[peerID] + + self.log.info("peer \(peerID) (nodeID: \(nodeID ?? "unknown")) didChange state: \(state.rawValue)") + + switch state { + case .connected: + // Cancel per-peer watchdog timer + // Cancel per-peer watchdog timer (if it exists for this specific socket) + // Note: We used to cancel on Handshake, but we can also cancel here effectively IF we trust the socket state. + // Actually, let's keep the logic consistent: Watchdog guards HANDSHAKE. + // But if we are connected, we *might* want to give it a grace period? + // No, sticking to "Cancel on Handshake" is safest. + // However, we MUST ensure old timers for *this* peerID are cleaned up if we reconnect? + + // Clean up previous timers for this socket if any (should be none as it's a new connection) + self.connectingTimeoutTimers[peerID]?.cancel() + self.connectingTimeoutTimers.removeValue(forKey: peerID) + + // NOTE: We do NOT reset consecutiveFailures here. + // We wait for Handshake + Stability Period. + + // Cancel any pending delayed restart (Recovery Succeeded!) + self.delayedRestartItem?.cancel() + self.delayedRestartItem = nil + + // Track previous peer count for KeepAlive lifecycle + let previousPeerCount = self.connectedPeers.count + + // Update connectedPeers IMMEDIATELY for responsive UI and correct mesh-empty checks + self.connectedPeers = session.connectedPeers + + // KeepAlive: Start only when transitioning from 0 → 1 peers + if previousPeerCount == 0 && self.connectedPeers.count >= 1 { + self.startKeepAlive() + } + + // Traffic Gating + Task { + do { + // OPTIMIZATION: Check if we are on WiFi + // If on WiFi (stable), wait only 100ms. + // If on AWDL/Cellular (unstable), wait the full 1.0s to let the mesh settle. + let isWiFi = NetworkMonitor.shared.isWiFi + let delay: UInt64 = isWiFi ? 500_000_000 : 1_500_000_000 // 0.5s vs 1.5s + + if isWiFi { + self.log.info("On WiFi: Fast-tracking handshake (0.5s delay)") + } else { + self.log.info("On AWDL: Slow-tracking handshake (1.5s delay) for stability") + } + + try await Task.sleep(nanoseconds: delay) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // VALIDATION CHECKS + guard !self.isRestarting else { return } + guard session == self.session else { return } + guard session.connectedPeers.contains(peerID) else { return } + + // Send handshake when a peer connects (safely after delay) + self.sendHandshake() + } + } catch { + self.log.error("Traffic gating interrupted: \(error.localizedDescription)") + } + } + + case .notConnected: + // Cancel per-peer watchdog timer + self.connectingTimeoutTimers[peerID]?.cancel() + self.connectingTimeoutTimers.removeValue(forKey: peerID) + + // Clean up NodeID tracking + if let nodeID = nodeID { + // Clear pending invite status + self.pendingInvites.removeValue(forKey: nodeID) + } + + // STABILITY CHECK: If we lose connection before Stability Timer fires, we KEEP the failure count. + if let nodeID = nodeID, let timer = self.stabilityTimers[nodeID] { + self.log.warning("Connection to \(nodeID) dropped before stability period! Preserving failure count: \(self.consecutiveFailures[nodeID] ?? 0)") + timer.invalidate() + self.stabilityTimers.removeValue(forKey: nodeID) + } + + // Check if we were previously connected to this peer + let wasConnected = self.connectedPeers.contains(peerID) + + // PRESERVE PARTIAL TRANSCRIPT: If peer had a live transcript, post it as final message + if let peerUser = self.connectedPeerUsers.first(where: { $0.id == peerID }) { + // liveTranscripts is now keyed by nodeID + let transcriptKey = peerUser.nodeID.isEmpty ? peerUser.name : peerUser.nodeID + if let partialText = self.liveTranscripts[transcriptKey], !partialText.isEmpty { + let finalMessage = MeshMessage( + id: UUID(), + senderID: peerUser.name, + senderNodeID: peerUser.nodeID, + senderRole: peerUser.role, + senderColorHex: peerUser.colorHex, + content: partialText, + isTranscription: true, + timestamp: Date() + ) + self.receivedMessages.append(finalMessage) + self.log.info("Posted partial transcript from disconnected peer \(peerUser.name)") + } + self.liveTranscripts.removeValue(forKey: transcriptKey) + } + + // Update connected peers list + if let currentSession = self.session { + self.connectedPeers = currentSession.connectedPeers + } else { + self.connectedPeers.removeAll { $0 == peerID } + } + + // Remove from connectedPeerUsers + self.connectedPeerUsers.removeAll { $0.id == peerID } + + // KeepAlive: Stop when transitioning from 1 → 0 peers + if self.connectedPeers.isEmpty { + self.stopKeepAlive() + } + + if wasConnected { + self.log.info("Connection dropped: \(peerID). Mesh size: \(self.connectedPeers.count)") + } + + // UNIFIED RECOVERY: Treats both handshake failures AND drops as candidates for Smart Retry. + // We NO LONGER restart services just because the mesh is empty, to prevent "Connection refused" errors for others. + + guard let nodeID = nodeID else { + self.log.info("Link lost for unknown nodeID peer. Ignoring.") + return + } + + // GHOST CHECK: If this is an older instance, ignore the failure + if let latest = self.latestByNodeID[nodeID], latest.peerID != peerID { + self.log.info("Ignoring failure of GHOST peer \(peerID). Current is \(latest.peerID).") + return + } + + // Track consecutive failures for poisoned state detection + let failures = (self.consecutiveFailures[nodeID] ?? 0) + 1 + self.consecutiveFailures[nodeID] = failures + self.log.info("Consecutive failures for nodeID \(nodeID): \(failures)") + + // Add exponential backoff cooldown + let backoff = self.calculateBackoff(failures: failures) + self.cooldownUntil[nodeID] = Date().addingTimeInterval(backoff) + self.log.info("Peer \(nodeID) in cooldown for \(backoff)s") + + // Don't restart if we have active connections + if !self.connectedPeers.isEmpty { + self.log.info("Handshake failed to \(nodeID), but we have active connections. Skipping restart.") + return + } + + // POISONED STATE: If too many consecutive failures, do full reset + if failures >= Self.poisonedThreshold { + self.log.error("POISONED STATE: \(failures) consecutive failures for \(nodeID). Full reset.") + self.restartServices(forcePoisonedRecovery: true) + return + } + + // SMART RETRY: Instead of restarting everything (Nuclear Option), just try to reconnect to this peer. + // Only LEADER initiates the retry to prevent race conditions. + if self.myNodeID > nodeID { + self.log.info("Handshake failed. I am LEADER. Scheduling Smart Retry in \(backoff)s.") + + DispatchQueue.main.asyncAfter(deadline: .now() + backoff) { [weak self] in + guard let self = self else { return } + + // Re-check conditions before firing + guard !self.connectedPeers.contains(peerID) else { return } + guard self.pendingInvites[nodeID] == nil else { return } + + // Ensure we still have a browser and session + guard let browser = self.serviceBrowser, let session = self.session else { return } + + self.log.info("Smart Retry: Re-inviting \(peerID) (nodeID: \(nodeID)).") + self.pendingInvites[nodeID] = peerID + + let contextData = try? JSONEncoder().encode([ + "nodeID": self.myNodeID, + "instance": String(self.myInstance) + ]) + + browser.invitePeer(peerID, to: session, withContext: contextData, timeout: 7) + } + } else { + self.log.info("Handshake failed. I am FOLLOWER. Waiting for Leader to retry.") + } + + + case .connecting: + // Per-peer watchdog: cancel only this peer on timeout, don't restart everything + // Try reverse lookup if nodeID not in peerNodeIDMap yet (e.g. from Pending Invites) + let resolvedNodeID = nodeID ?? self.pendingInvites.first(where: { $0.value == peerID })?.key + + guard let nodeID = resolvedNodeID else { + // This happens if THEY invited US and we haven't processed the context fully yet. + // Ideally, `advertiser` delegate should have populated peerNodeIDMap. + self.log.info("Connecting to peer with unknown nodeID: \(peerID) - Watchdog might be skipped if not resolved.") + return + } + + // Cancel any existing timer for this specific socket + self.connectingTimeoutTimers[peerID]?.cancel() + + // Capture expected instance to detect if peer updated while connecting + let expectedInstance = self.latestByNodeID[nodeID]?.instance ?? 0 + + // Longer timeout when not on WiFi (cellular AWDL is slower/unreliable) + // OPTIMIZATION: Relax WiFi timeout to 6.0s (was 3.5s) to avoid premature timeouts + let timeout: Double = NetworkMonitor.shared.isWiFi ? 6.0 : 15.0 + let item = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + // ABORT GUARD: If we have since discovered a newer instance, don't punish this nodeID + if let current = self.latestByNodeID[nodeID], current.instance > expectedInstance { + self.log.info("Watchdog: Aborting timeout for peer \(peerID) (node \(nodeID)) - found newer instance \(current.instance) > \(expectedInstance)") + return + } + + self.log.error("Watchdog: Handshake TIMED OUT (\(timeout)s) for peer \(peerID).") + + // Kill THIS specific socket + self.session?.cancelConnectPeer(peerID) + + // Cancel pending invite logic ONLY if it matches this peer + if let peerToCancel = self.pendingInvites[nodeID], peerToCancel == peerID { + self.log.info("Watchdog: Clearing pending invite for \(nodeID)") + self.pendingInvites.removeValue(forKey: nodeID) + } + + // Track failure + let failures = (self.consecutiveFailures[nodeID] ?? 0) + 1 + self.consecutiveFailures[nodeID] = failures + + // Add cooldown with exponential backoff + let backoff = self.calculateBackoff(failures: failures) + self.cooldownUntil[nodeID] = Date().addingTimeInterval(backoff) + self.log.info("Peer \(nodeID) quarantined for \(backoff)s (failure #\(failures))") + + // POISONED STATE: Full reset only if mesh is empty + if failures >= Self.poisonedThreshold { + if self.connectedPeers.isEmpty { + self.log.error("POISONED STATE: \(failures) consecutive failures. Full reset.") + self.restartServices(forcePoisonedRecovery: true) + } else { + self.log.error("Poisoned threshold hit for \(nodeID) but mesh is active; quarantining only.") + } + return + } + + // Only restart if mesh is empty (Legacy logic kept for safety but largely disabled by checks) + if self.connectedPeers.isEmpty { + self.scheduleRestartIfStillEmpty() + } + } + self.connectingTimeoutTimers[peerID] = item + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: item) + + @unknown default: + break + } + } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + // Safe check on main thread? No, didReceive is high volume. + // We can just capture self.session in the async block or check it against the passed session. + // But for data, it's less critical to check identity for *stablity* (only valid sessions receive data). + // However, consistency is good. + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard session == self.session else { return } + + do { + let message = try JSONDecoder().decode(MeshMessage.self, from: data) + + // GHOST FILTER: Ignore messages from older instances if we know a newer one + if !message.senderNodeID.isEmpty { + if let latest = self.latestByNodeID[message.senderNodeID] { + if message.senderInstance > latest.instance { + // SELF-HEALING: We found a newer instance via Handshake that Browser didn't report yet. + // TRUST IT immediately to fix race conditions. + self.log.info("Ghost Filter: Updated latest instance for \(message.senderNodeID) to \(message.senderInstance) based on Handshake.") + self.latestByNodeID[message.senderNodeID] = (peerID, message.senderInstance) + } else if latest.peerID != peerID { + // It's genuinely an older instance (Ghost) + self.log.info("Ignoring message from GHOST peer \(peerID) (NodeID: \(message.senderNodeID)). Current is \(latest.peerID)") + return + } + } + } + + // Keep-Alive / Gossip Handling + if message.isKeepAlive || message.content == "💓" { + // Gossip: Check for missing peers + if let theirConnectedNodes = message.connectedNodeIDs { + // 1. Identify nodes they have that WE don't (clique gaps) + // Filter out empty IDs and our own ID + let missingNodeIDs = theirConnectedNodes.filter { nid in + !nid.isEmpty && + nid != self.myNodeID && + !self.connectedPeerUsers.contains(where: { $0.nodeID == nid }) + } + + if !missingNodeIDs.isEmpty { + self.log.info("Gossip: Peer \(message.senderID) has connections I lack: \(missingNodeIDs)") + + for missingID in missingNodeIDs { + // 2. Check if we have discovered this missing node + if let discovery = self.latestByNodeID[missingID] { + + // 3. SAFETY CHECK: Are we already connected at the socket level? + // If yes, DO NOT Invite. Just wait for the handshake. + // (Gossip sees them as missing because they aren't in connectedPeerUSERS yet) + if self.session?.connectedPeers.contains(discovery.peerID) == true { + self.log.info("Gossip Repair: Ignoring missing \(missingID). Socket is connected to \(discovery.peerID), processing handshake...") + continue + } + + // We know them! REPAIR strategy: + // If we are "Leader" relative to them, active invite. + // If we are "Follower", we just log (or potentially bump state). + // Note: Using string comparison for nodeID is consistent with Smart Retry. + if self.myNodeID > missingID { + self.log.info("Gossip Repair: I am LEADER for missing \(missingID) (discovered as \(discovery.peerID)). Triggering Invite.") + // Re-use logic from foundPeer? Or just direct invite? + // Direct invite safety check: + if self.pendingInvites[missingID] == nil { + self.log.info("Gossip Repair: Sending Invite to \(discovery.peerID)") + // 2.0s delay to be safe, or immediate? + // Immediate is fine here as we've likely known them for a while if they are in 'latestByNodeID'. + let contextData = self.createInviteContext() + self.serviceBrowser?.invitePeer(discovery.peerID, to: self.session!, withContext: contextData, timeout: 10) + self.pendingInvites[missingID] = discovery.peerID + } + } else { + // REVERSE INVITE: Break the stalemate if Leader thinks we're connected but we aren't. + if self.pendingInvites[missingID] == nil { + self.log.info("Gossip Repair: I am FOLLOWER for missing \(missingID). Leader hasn't invited me (Half-Open?). TRIGGERING REVERSE INVITE.") + self.log.info("Gossip Repair: Sending Reverse Invite to \(discovery.peerID)") + let contextData = self.createInviteContext() + self.serviceBrowser?.invitePeer(discovery.peerID, to: self.session!, withContext: contextData, timeout: 10) + self.pendingInvites[missingID] = discovery.peerID + } + } + } else { + // We haven't even discovered them yet. Nothing we can do but wait for Bonjour. + // self.log.debug("Gossip: Missing \(missingID) is unknown to me (not in latestByNodeID).") + } + } + } + } + return + } + + if message.isHandshake { + // Use nodeID for reliable identity mapping (required, not optional) + let nodeID = message.senderNodeID + + // CRITICAL FIX: Cancel watchdog timer now that we have a handshake + // Cancel watchdog: Valid handshake received for THIS peer + self.connectingTimeoutTimers[peerID]?.cancel() + self.connectingTimeoutTimers.removeValue(forKey: peerID) + self.log.info("Watchdog: Cancelled timer for peer \(peerID) (Handshake received)") + + // Update peerNodeIDMap with handshake info + if !nodeID.isEmpty { + self.peerNodeIDMap[peerID] = nodeID + } + + let newUser = PeerUser( + peerID: peerID, + nodeID: nodeID, + name: message.senderID, + colorHex: message.senderColorHex, + role: message.senderRole + ) + + // DEATH SPIRAL FIX: Start Stability Timer (15s) + // We do NOT reset failures immediately. We wait to see if connection holds. + if !nodeID.isEmpty { + self.stabilityTimers[nodeID]?.invalidate() // Reset if exists + self.log.info("Starting 15s Stability Timer for \(nodeID)...") + + let timer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.log.info("Stability Period Passed! Resetting failures for \(nodeID).") + self.consecutiveFailures[nodeID] = 0 + self.cooldownUntil.removeValue(forKey: nodeID) + self.stabilityTimers.removeValue(forKey: nodeID) + } + self.stabilityTimers[nodeID] = timer + } + + // Match by nodeID (reliable) instead of displayName + if !nodeID.isEmpty, let index = self.connectedPeerUsers.firstIndex(where: { $0.nodeID == nodeID }) { + self.log.info("UI Update: Updating existing user for nodeID \(nodeID) (peerID mismatch allowed)") + self.connectedPeerUsers[index] = newUser + } else if let index = self.connectedPeerUsers.firstIndex(where: { $0.id == peerID }) { + // Fallback: match by MCPeerID + self.log.info("UI Update: Updating existing user for peerID \(peerID)") + self.connectedPeerUsers[index] = newUser + } else { + self.log.info("UI Update: Appending new user \(peerID) (nodeID: \(nodeID))") + self.connectedPeerUsers.append(newUser) + } + + self.log.info("Handshake received from \(message.senderID) (nodeID: \(nodeID))") + } else if message.isPartial { + // Key by nodeID (we enforce senderNodeID on send, so this should always be set) + guard !message.senderNodeID.isEmpty else { + self.log.warning("Received partial with empty senderNodeID - ignoring") + return + } + if message.content.isEmpty { + self.liveTranscripts.removeValue(forKey: message.senderNodeID) + } else { + self.liveTranscripts[message.senderNodeID] = message.content + } + } else { + // Final message received + self.receivedMessages.append(message) + // Clear the live transcript for this sender + if !message.senderNodeID.isEmpty { + self.liveTranscripts.removeValue(forKey: message.senderNodeID) + } + } + } catch { + self.log.error("Error decoding message: \(error.localizedDescription)") + } + } + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + log.error("Receiving streams is not supported") + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + log.error("Receiving resources is not supported") + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { + log.error("Receiving resources is not supported") + } +} diff --git a/AtTable/NetworkMonitor.swift b/AtTable/NetworkMonitor.swift new file mode 100644 index 0000000..9aa8d3f --- /dev/null +++ b/AtTable/NetworkMonitor.swift @@ -0,0 +1,26 @@ +import Foundation +import Network +import Combine + +class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + @Published var isConnected: Bool = true + @Published var isWiFi: Bool = true + + init() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + self?.isWiFi = path.usesInterfaceType(.wifi) + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} diff --git a/AtTable/NodeIdentity.swift b/AtTable/NodeIdentity.swift new file mode 100644 index 0000000..80bef19 --- /dev/null +++ b/AtTable/NodeIdentity.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Provides a stable node identity for mesh networking. +/// - `nodeID`: Stable per install (persisted in UserDefaults) +/// - `instance`: Monotonic counter per session start +struct NodeIdentity { + private static let nodeIDKey = "com.attable.mesh.nodeID" + private static let instanceKey = "com.attable.mesh.instance" + + /// Get or create a stable nodeID for this installation + static var nodeID: String { + if let existing = UserDefaults.standard.string(forKey: nodeIDKey) { + return existing + } + let newID = UUID().uuidString + UserDefaults.standard.set(newID, forKey: nodeIDKey) + return newID + } + + /// Increment and return the current instance counter + static func nextInstance() -> Int { + let current = UserDefaults.standard.integer(forKey: instanceKey) + let next = current + 1 + UserDefaults.standard.set(next, forKey: instanceKey) + return next + } + + /// Get the current instance without incrementing (for reference) + static var currentInstance: Int { + return UserDefaults.standard.integer(forKey: instanceKey) + } +} diff --git a/AtTable/OnboardingView.swift b/AtTable/OnboardingView.swift new file mode 100644 index 0000000..8508076 --- /dev/null +++ b/AtTable/OnboardingView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +struct OnboardingView: View { + @Binding var isOnboardingComplete: Bool + @Binding var userName: String + @Binding var userRole: UserRole + @Binding var userColorHex: String + + let colors: [String] = [ + "#FF0055", // Neon Red + "#00FF99", // Neon Green + "#00CCFF", // Neon Blue + "#9900FF", // Neon Purple + "#FF9900", // Neon Orange + "#FF00CC", // Neon Pink + "#00FFEA", // Cyan + "#FFFFFF" // White + ] + @State private var showQRCode = false + + var body: some View { + ZStack { + MeshBackground(colors: [ + Color(hex: userColorHex), + userRole == .deaf ? .purple : .blue, + .black + ]) + + // Top Right QR Code Link + VStack { + HStack { + Spacer() + Button(action: { + showQRCode = true + }) { + HStack(spacing: 6) { + Image(systemName: "qrcode") + Text("QR code for the app") + } + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(8) + .background(.ultraThinMaterial) + .cornerRadius(12) + } + } + Spacer() + } + .padding(.top, 50) // Safe area + .padding(.horizontal) + .ignoresSafeArea() + + VStack(spacing: 40) { + DesignSystem.Typography.title("Welcome") + .foregroundColor(.white) + .shadow(radius: 10) + + VStack(spacing: 25) { + VStack(alignment: .leading, spacing: 8) { + Label("Your name", systemImage: "person.fill") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + + TextField("Enter Name", text: $userName) + .padding() + .background(.ultraThinMaterial) + .cornerRadius(12) + .foregroundColor(.white) + } + + VStack(alignment: .leading, spacing: 8) { + Label("I am...", systemImage: "ear") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + + Picker("Role", selection: $userRole) { + ForEach(UserRole.allCases, id: \.self) { role in + Text(role.displayName).tag(role) + } + } + .pickerStyle(SegmentedPickerStyle()) + .colorMultiply(.white) + .onChange(of: userRole) { _, newRole in + withAnimation { + if newRole == .hearing { + userColorHex = "#00FF99" // Neon Green + } else { + userColorHex = "#00CCFF" // Neon Blue + } + } + } + + } + + VStack(alignment: .leading, spacing: 15) { + Label("Pick your Aura color", systemImage: "paintpalette.fill") + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: 15) { + ForEach(colors, id: \.self) { color in + Circle() + .fill(Color(hex: color)) + .frame(width: 44, height: 44) + .overlay( + Circle() + .stroke(.white, lineWidth: userColorHex == color ? 4 : 0) + .shadow(color: Color(hex: color).opacity(0.8), radius: 10) + ) + .scaleEffect(userColorHex == color ? 1.1 : 1.0) + .onTapGesture { + withAnimation(.spring()) { + userColorHex = color + } + } + } + } + } + } + .padding(30) + .liquidCard() + .padding(.horizontal) + + Button(action: { + withAnimation { + if !userName.isEmpty { + isOnboardingComplete = true + } + } + }) { + Text("Start Conversation") + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding() + .background( + LinearGradient(colors: [.white, .white.opacity(0.8)], startPoint: .top, endPoint: .bottom) + ) + .cornerRadius(20) + .glowingEdge(color: .white) + } + .disabled(userName.isEmpty) + .opacity(userName.isEmpty ? 0.5 : 1) + .padding(.horizontal, 40) + } + } + .sheet(isPresented: $showQRCode) { + QRCodeView() + } + } +} + +// Keep Hex Color extension or ensure it's in Constants/Shared +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/AtTable/PeerUser.swift b/AtTable/PeerUser.swift new file mode 100644 index 0000000..71b9799 --- /dev/null +++ b/AtTable/PeerUser.swift @@ -0,0 +1,18 @@ +import Foundation +import MultipeerConnectivity + +struct PeerUser: Identifiable, Hashable { + let id: MCPeerID + let nodeID: String // Stable identifier from handshake (for reliable identity) + let name: String + let colorHex: String + let role: UserRole // Role from handshake (for data integrity) + + init(peerID: MCPeerID, nodeID: String = "", name: String? = nil, colorHex: String = "#808080", role: UserRole = .hearing) { + self.id = peerID + self.nodeID = nodeID + self.name = name ?? peerID.displayName + self.colorHex = colorHex + self.role = role + } +} diff --git a/AtTable/QRCodeView.swift b/AtTable/QRCodeView.swift new file mode 100644 index 0000000..a012579 --- /dev/null +++ b/AtTable/QRCodeView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct QRCodeView: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + + VStack(spacing: 30) { + // Header + HStack { + Spacer() + Button(action: { + dismiss() + }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 30)) + .foregroundColor(.white.opacity(0.8)) + } + } + .padding() + + Spacer() + + Image("qrcode") + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(maxWidth: 280, maxHeight: 280) + .padding(20) + .background(Color.white) // QR code usually needs white background to be scannable + .cornerRadius(20) + .shadow(color: .white.opacity(0.2), radius: 20) + + Text("Download At-Table app from Apple Appstore") + .font(.title3) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .padding(.horizontal, 40) + + Spacer() + } + } + } +} diff --git a/AtTable/SpeechRecognizer.swift b/AtTable/SpeechRecognizer.swift new file mode 100644 index 0000000..7144664 --- /dev/null +++ b/AtTable/SpeechRecognizer.swift @@ -0,0 +1,262 @@ +import Foundation +import Speech +import AVFoundation +import SwiftUI +import Combine + +class SpeechRecognizer: ObservableObject { + @Published var transcript: String = "" + @Published var isRecording: Bool = false + @Published var error: String? + + // Callback for when a final result is ready to be broadcast + var onFinalResult: ((String) -> Void)? + // Callback for partial results + var onPartialResult: ((String) -> Void)? + + private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private let audioEngine = AVAudioEngine() + + private var silenceTimer: Timer? + + init() { + requestAuthorization() + } + + private func requestAuthorization() { + SFSpeechRecognizer.requestAuthorization { authStatus in + DispatchQueue.main.async { + switch authStatus { + case .authorized: + break + case .denied: + self.error = "Speech recognition authorization denied" + case .restricted: + self.error = "Speech recognition restricted on this device" + case .notDetermined: + self.error = "Speech recognition not yet authorized" + @unknown default: + self.error = "Unknown authorization status" + } + } + } + } + + func startRecording() { + // If already recording, we might be calling this to restart, so we shouldn't necessarily just return. + // But for the public API toggle, we check `isRecording`. + // To support internal restarts, we might want to separate the logic. + // However, `stopRecording` sets `isRecording = false`. + + if isRecording { + stopRecording() + // If the user tapped the button, we stop. + // If we are auto-restarting, we want to start again immediately. + // Let's rely on the caller or proper state management. + // If this is called from the UI toggle, we stop. + return + } + + startRecordingInternal() + } + + private func startRecordingInternal() { + // Reset state + lastFlushedTranscript = "" + currentFullTranscription = "" + + // Cancel previous task if any + if recognitionTask != nil { + recognitionTask?.cancel() + recognitionTask = nil + } + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + self.error = "Audio session setup failed: \(error.localizedDescription)" + return + } + + recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + + guard let recognitionRequest = recognitionRequest else { + self.error = "Unable to create recognition request" + return + } + + // Check network status to determine recognition mode + if NetworkMonitor.shared.isConnected { + // Force on-device recognition to false as per PRD for higher accuracy when online + recognitionRequest.requiresOnDeviceRecognition = false + print("SpeechRecognizer: Using server-based recognition (Online)") + } else { + // Fallback to on-device recognition when offline + recognitionRequest.requiresOnDeviceRecognition = true + print("SpeechRecognizer: Using on-device recognition (Offline)") + } + recognitionRequest.shouldReportPartialResults = true + + // Keep a local reference to the request to check against stale callbacks + let currentRequest = recognitionRequest + + let inputNode = audioEngine.inputNode + + // Remove any existing tap to prevent "Tap already installed" crash + inputNode.removeTap(onBus: 0) + + recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { [weak self] result, error in + guard let self = self else { return } + + // Safety Check: Ignore callbacks from old/stale requests + // This prevents a previous session's error/completion from stopping the NEW session + guard self.recognitionRequest === currentRequest else { + return + } + + var isFinal = false + + if let result = result { + DispatchQueue.main.async { + let fullText = result.bestTranscription.formattedString + self.currentFullTranscription = fullText + + // Robust Delta Calculation + var newText = fullText + + if !self.lastFlushedTranscript.isEmpty { + if fullText.hasPrefix(self.lastFlushedTranscript) { + // Perfect match logic + newText = String(fullText.dropFirst(self.lastFlushedTranscript.count)) + } else if fullText.lowercased().hasPrefix(self.lastFlushedTranscript.lowercased()) { + // Case-insensitive match logic + newText = String(fullText.dropFirst(self.lastFlushedTranscript.count)) + } else { + // Fallback: History rewritten (punctuation/spelling change). + // Blindly drop the length of the old text to avoid duplicating history. + let dropCount = min(fullText.count, self.lastFlushedTranscript.count) + newText = String(fullText.dropFirst(dropCount)) + } + } + + self.transcript = newText.trimmingCharacters(in: .whitespacesAndNewlines) + + // Trigger partial result callback + self.onPartialResult?(self.transcript) + + // Reset silence timer on every new result + self.resetSilenceTimer() + } + isFinal = result.isFinal + + // If it's a final result, trigger the callback to broadcast + if isFinal { + DispatchQueue.main.async { + // Send the DELTA (self.transcript) not the full result + if !self.transcript.isEmpty { + self.onFinalResult?(self.transcript) + } + self.silenceTimer?.invalidate() + } + } + } + + if error != nil || isFinal { + // Perform cleanup + self.audioEngine.stop() + inputNode.removeTap(onBus: 0) + self.recognitionRequest = nil + self.recognitionTask = nil + self.silenceTimer?.invalidate() // Safety invalidation + + if error != nil { + DispatchQueue.main.async { + self.isRecording = false + } + } + } + } + + let recordingFormat = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, when in + self.recognitionRequest?.append(buffer) + } + + audioEngine.prepare() + + do { + try audioEngine.start() + isRecording = true + transcript = "Listening..." + } catch { + self.error = "Audio engine start failed: \(error.localizedDescription)" + } + } + + func stopRecording(shouldUpdateState: Bool = true) { + silenceTimer?.invalidate() + + // Manual Flush: If we have pending text, send it now to guarantee it's not lost. + if !transcript.isEmpty && transcript != "Listening..." { + onFinalResult?(transcript) + transcript = "" + } else { + // Also clear if it was just "Listening..." + transcript = "" + } + + // Always trigger an empty partial result to clear the UI on the receiver side + onPartialResult?("") + + audioEngine.stop() + // We use cancel() here instead of endAudio() because we just handled the final result manually. + // This prevents double-sending if endAudio() were to trigger a final callback later. + recognitionTask?.cancel() + + // Deactivate Audio Session to release Bluetooth/Mic resources + // This is critical to prevent interference with MultipeerConnectivity + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("Failed to deactivate audio session: \(error)") + } + + if shouldUpdateState { + isRecording = false + } + } + + private func resetSilenceTimer() { + silenceTimer?.invalidate() + silenceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in + self?.handleSilence() + } + } + + private var lastFlushedTranscript: String = "" + private var currentFullTranscription: String = "" + + private func handleSilence() { + // Soft flush: Send what we have, clear local view, but keep engine running + guard !transcript.isEmpty && transcript != "Listening..." else { return } + + // Send the final result + onFinalResult?(transcript) + + // Mark the current *full* text as "flushed" so we ignore it in future updates + // We use the full string that the recognizer currently has + lastFlushedTranscript = currentFullTranscription + + // Clear local transcript view + transcript = "" + onPartialResult?("") + + print("Silence detected, flushed text. Engine continuing...") + } +} + diff --git a/AtTable/UserRole.swift b/AtTable/UserRole.swift new file mode 100644 index 0000000..3c360e3 --- /dev/null +++ b/AtTable/UserRole.swift @@ -0,0 +1,13 @@ +import Foundation + +enum UserRole: String, Codable, CaseIterable { + case deaf + case hearing + + var displayName: String { + switch self { + case .deaf: return "Deaf / HoH" + case .hearing: return "Hearing" + } + } +} diff --git a/MPC_how_it_works.md b/MPC_how_it_works.md new file mode 100644 index 0000000..a3c6cbc --- /dev/null +++ b/MPC_how_it_works.md @@ -0,0 +1,255 @@ +# Multipeer Connectivity (MPC) Architecture + +This document explains how AtTable uses Apple's Multipeer Connectivity framework to create a peer-to-peer mesh network for real-time communication between deaf and hearing users. + +--- + +## Overview + +AtTable uses **Multipeer Connectivity (MPC)** to establish direct device-to-device connections without requiring a central server. The app supports connections over: + +- **Wi-Fi** (same network) +- **Peer-to-peer Wi-Fi** (AWDL - Apple Wireless Direct Link) +- **Bluetooth** + +When devices aren't on the same Wi-Fi network (e.g., on 5G/cellular), MPC automatically falls back to **AWDL** for peer-to-peer discovery and data transfer. + +--- + +## User Onboarding Flow + +### 1. Initial Setup (`OnboardingView.swift`) + +When a user launches the app: + +1. They enter their **name** +2. Select their **role** (Deaf or Hearing) +3. Choose an **aura color** (for visual identity in the mesh) +4. Tap **"Start Conversation"** to enter the mesh + +``` +User launches app → OnboardingView → Enter details → ChatView (mesh starts) +``` + +### 2. Identity Generation (`NodeIdentity.swift`) + +Upon first launch, the app generates a **stable Node Identity**: + +- **`nodeID`**: A UUID persisted in UserDefaults (stable per app installation) +- **`instance`**: A monotonic counter that increments each time a session starts + +This identity system allows the mesh to: +- Reliably identify users across reconnections +- Detect and filter "ghost" peers (stale connections from previous sessions) +- Handle device reboots gracefully + +--- + +## Network Connection Process + +### Discovery & Connection (`MultipeerSession.swift`) + +When `ChatView` appears, it calls `multipeerSession.start()`, which: + +1. **Sets up the MCSession** with encryption disabled (for faster AWDL connections) +2. **Starts browsing** for nearby peers using `MCNearbyServiceBrowser` +3. **Starts advertising** (after 0.5s delay) using `MCNearbyServiceAdvertiser` + +### Wi-Fi vs Cellular/5G Connections + +| Network Type | Connection Method | Handshake Delay | Connection Time | +|--------------|-------------------|-----------------|-----------------| +| **Wi-Fi (same network)** | Infrastructure Wi-Fi | 0.5 seconds | Near-instant | +| **Cellular/5G** | AWDL (peer-to-peer Wi-Fi) | 1.5 seconds | Up to 60 seconds | + +The app uses `NetworkMonitor.swift` to detect the current network type and adjusts timing: + +```swift +let isWiFi = NetworkMonitor.shared.isWiFi +let delay = isWiFi ? 0.5s : 1.5s // Slower for AWDL stability +``` + +### Deterministic Leader/Follower Protocol + +To prevent connection races (both devices trying to invite each other), the app uses a **deterministic leader election**: + +```swift +if myNodeID > theirNodeID { + // I am LEADER - I will send the invite +} else { + // I am FOLLOWER - I wait for their invite +} +``` + +This ensures exactly one device initiates each connection. + +--- + +## Handshake Protocol + +Once connected at the socket level, devices exchange **handshake messages** containing: + +```swift +struct MeshMessage { + var senderNodeID: String // Stable identity + var senderInstance: Int // Session counter (for ghost detection) + var senderRole: UserRole // Deaf or Hearing + var senderColorHex: String // Aura color + var isHandshake: Bool // Identifies this as handshake +} +``` + +The handshake: + +1. Registers the peer in `connectedPeerUsers` for UI display +2. Starts a **15-second stability timer** before clearing failure counters +3. Maps the `MCPeerID` to the stable `nodeID` for reliable identification + +--- + +## User Leaving the Conversation + +### Explicit Leave (`ChatView.swift`) + +When a user taps **"Leave"**: + +```swift +Button(action: { + speechRecognizer.stopRecording() // Stop audio transcription + multipeerSession.stop() // Disconnect from mesh + isOnboardingComplete = false // Return to onboarding +}) +``` + +### Disconnect Cleanup (`MultipeerSession.disconnect()`) + +The `disconnect()` function performs complete cleanup: + +1. **Cancel pending work**: Recovery tasks, connection timers +2. **Stop services**: Advertising and browsing +3. **Clear delegates**: Prevent zombie callbacks +4. **Disconnect session**: `session?.disconnect()` +5. **Clear all state**: + - `connectedPeers` / `connectedPeerUsers` + - `pendingInvites` / `latestByNodeID` + - `cooldownUntil` / `consecutiveFailures` +6. **Stop keep-alive heartbeats** + +### Partial Transcript Preservation + +If a peer disconnects mid-speech, their **partial transcript is preserved** as a final message: + +```swift +if let partialText = liveTranscripts[peerKey], !partialText.isEmpty { + let finalMessage = MeshMessage(content: partialText, ...) + receivedMessages.append(finalMessage) +} +``` + +--- + +## Rejoining the Conversation + +### Identity Recovery + +When a user returns to the conversation: + +1. App resets `isOnboardingComplete = false` on every launch (intentional - forces Login screen) +2. User completes onboarding again (name/role/color preserved in `@AppStorage`) +3. `multipeerSession.start()` called again + +### Instance Increment + +The key to reliable rejoining is the **instance counter**: + +```swift +myInstance = NodeIdentity.nextInstance() // Monotonically increasing +``` + +When other devices see the new instance: + +1. **Ghost Detection**: Old connections with lower instances are rejected +2. **Cooldown Clear**: Any cooldowns from previous failures are removed +3. **Fresh Connect**: The leader initiates a new invitation + +### Handling Stale Peers + +The mesh uses multiple mechanisms to handle rejoins: + +| Mechanism | Purpose | +|-----------|---------| +| **Ghost Filtering** | Reject messages/invites from older instances | +| **Cooldown Clear** | Give returning peers a fresh chance | +| **Half-Open Deadlock Fix** | If we think we're connected but they invite us, accept the new invite | +| **Stability Timer** | Only reset failure counts after 15s of stable connection | + +--- + +## Keep-Alive & Mesh Health + +### Heartbeat System + +When connected, the mesh sends **heartbeats every 10 seconds**: + +```swift +let message = MeshMessage( + content: "💓", + isKeepAlive: true, + connectedNodeIDs: connectedPeerUsers.map { $0.nodeID } // Gossip +) +``` + +### Gossip Protocol + +Heartbeats include a list of connected peers, enabling **clique repair**: + +1. Device A receives heartbeat from Device B +2. If B knows Device C but A doesn't, A can proactively invite C +3. This heals mesh partitions without requiring everyone to be discoverable + +--- + +## Connection Recovery + +### Exponential Backoff + +Failed connections trigger increasing cooldown periods: + +```swift +// 0.5s → 1.0s → 2.0s → 4.0s → ... → max 30s +let delay = min(0.5 * pow(2, failures - 1), 30.0) +``` + +### Smart Retry + +Instead of restarting everything, failed connections are retried individually: + +1. Only the **leader** initiates retries (prevents race conditions) +2. Retries respect cooldown periods +3. After 5 consecutive failures → **"Poisoned State"** triggers full reset + +### Poisoned State Recovery + +If a peer has too many consecutive failures: + +```swift +if failures >= 5 { + restartServices(forcePoisonedRecovery: true) + // Creates new MCPeerID, clears all cooldowns +} +``` + +--- + +## Summary + +| Event | What Happens | +|-------|--------------| +| **User joins** | NodeID retrieved, instance incremented, advertise + browse started | +| **On Wi-Fi** | Fast handshake (0.5s), near-instant connections | +| **On 5G/Cellular** | AWDL used, slower handshake (1.5s), up to 60s to connect | +| **User leaves** | Full cleanup, partial transcripts preserved | +| **User rejoins** | New instance number, ghosts filtered, cooldowns cleared | +| **Connection fails** | Exponential backoff, smart retry by leader only | + +The architecture prioritizes **reliability over speed**, using defensive mechanisms like ghost filtering, stability timers, and gossip-based clique repair to maintain mesh health despite the inherent unreliability of peer-to-peer wireless connections. diff --git a/MeshInfo.plist b/MeshInfo.plist new file mode 100644 index 0000000..a17c54c --- /dev/null +++ b/MeshInfo.plist @@ -0,0 +1,11 @@ + + + + + NSBonjourServices + + _access-mesh._tcp + _access-mesh._udp + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e50b85d --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# At-Table + +**Bridging the gap between Deaf and Hearing conversations through local mesh networking and calm design.** + +At-Table is an iOS accessibility application designed to enable seamless, simultaneous conversations between multiple deaf and hearing individuals. By leveraging a full mesh network, devices connect automatically without the need for Wi-Fi routers or cellular data, creating a local "table" of communication. + +The app prioritizes a stress-free environment for deaf users, utilizing calming visuals to counteract the anxiety often associated with following rapid-fire hearing conversations. + +## 🌟 Key Features + +### Core Communication +* **Full Mesh Networking:** Devices enter a peer-to-peer full mesh network using Apple's Multipeer Connectivity. No manual pairing or internet connection is required to chat. +* **Real-Time Transcription (Hearing Role):** Hearing users speak naturally. The app transcribes speech in real-time and automatically posts the text as a message to the group once the speaker pauses. +* **Text & Quick Replies (Deaf Role):** Deaf users can type messages or use one-tap "Quick Reply" chips (Yes, No, Hold on, Thanks) for rapid interaction. +* **Role-Based UI:** Distinct interfaces tailored for "Deaf/HoH" and "Hearing" users, optimized for their specific communication needs. + +### Visual Design & Atmosphere +* **Calm ASMR Aesthetic:** The background features a slow-moving, blurring mesh of "Liquid Glass" orbs. This visual ASMR is designed to induce a calm mood, helping alleviate the cognitive load and stress deaf users experience during hearing conversations. +* **Personalized Auras:** Users select a "Neon Aura" color during onboarding. This color identifies them in the mesh and dynamically influences the background animation of their device. +* **Live Transcript Streaming:** Hearing users' speech appears as "partial" streaming text on receiving devices before becoming a finalized message, allowing for faster reading speeds. + +### Technical Resilience +* **Ghost Filtering:** Advanced logic prevents "ghost" connections (stale peer signals) from clogging the network. +* **Smart Recovery:** Includes "Identity Thrashing" prevention and auto-reconnection logic to handle interruptions or app backgrounding seamlessly. +* **Hybrid Speech Recognition:** Utilizes server-based speech recognition for high accuracy when online, with a seamless fallback to on-device recognition when offline. + +## 📱 How It Works + +1. **Onboarding:** Users enter a display name, select their role (Deaf/HoH or Hearing), and choose an identifying color. +2. **Discovery:** The app automatically advertises and browses for nearby devices running At-Table. +3. **Connection:** Devices negotiate a connection automatically using a leader/follower algorithm to prevent collision loops. +4. **The Conversation:** + * **Hearing users** simply hold their phone; the microphone listens for speech, visualizes it, and broadcasts it. + * **Deaf users** read incoming bubbles and participate via text. + + +## 🔒 Privacy + +* **Transient Data:** Messages and transcriptions are ephemeral. They exist only for the duration of the session and are not stored in any persistent database or uploaded to a cloud server (other than the temporary audio buffer sent to Apple for transcription if online). +* **Local Connectivity:** Chat data flows directly between devices over Wi-Fi/Bluetooth peer-to-peer protocols. \ No newline at end of file diff --git a/Screenshots/iPad/1.PNG b/Screenshots/iPad/1.PNG new file mode 100644 index 0000000..b3da124 Binary files /dev/null and b/Screenshots/iPad/1.PNG differ diff --git a/Screenshots/iPad/2.PNG b/Screenshots/iPad/2.PNG new file mode 100644 index 0000000..5530189 Binary files /dev/null and b/Screenshots/iPad/2.PNG differ diff --git a/Screenshots/iPhone/1.png b/Screenshots/iPhone/1.png new file mode 100644 index 0000000..004f38a Binary files /dev/null and b/Screenshots/iPhone/1.png differ diff --git a/Screenshots/iPhone/2.PNG b/Screenshots/iPhone/2.PNG new file mode 100644 index 0000000..14715fb Binary files /dev/null and b/Screenshots/iPhone/2.PNG differ diff --git a/check_build.sh b/check_build.sh new file mode 100755 index 0000000..f04b2f8 --- /dev/null +++ b/check_build.sh @@ -0,0 +1,31 @@ +#!/bin/zsh +set -o pipefail # Fail if xcodebuild fails, even with xcbeautify + +# --- Configuration --- +TARGET_NAME="AtTable" +DEVICE_NAME="iPhone 17 Pro" +BUILD_PATH="$(pwd)/build" + +echo "🔍 Checking compilation for $TARGET_NAME..." + +# Build Only (No Install/Launch) +# Uses -target instead of -scheme to bypass potential scheme misconfigurations +# Explicitly unsets CC/CXX/LIBCLANG_PATH to avoid environment pollution +# Also overrides them in xcodebuild arguments to ensure Xcode uses default toolchain +# Uses SYMROOT instead of -derivedDataPath because -derivedDataPath requires -scheme +env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \ + -target "$TARGET_NAME" \ + -sdk iphonesimulator \ + -destination "platform=iOS Simulator,name=$DEVICE_NAME" \ + -configuration Debug \ + SYMROOT="$BUILD_PATH" \ + CC=clang CXX=clang++ LIBCLANG_PATH= \ + build | xcbeautify + +# Check exit code of the pipeline +if [ $? -eq 0 ]; then + echo "✅ Build Succeeded. No errors found." +else + echo "❌ Build Failed." + exit 1 +fi