commit d89aedd5af4d209a690bb60c37295c0b7158d4b5 Author: Kenneth Date: Thu Jan 8 19:16:32 2026 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eaf64f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,293 @@ +# Created by https://www.toptal.com/developers/gitignore/api/gradle,android,androidstudio,xcode,macos,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,android,androidstudio,xcode,macos,windows,linux + +### Project-specific ### +# Xcode build/caches (repo contains IrisCompanion) +DerivedData/ +.derivedData/ +.xcode_home/ +.tmp/ +.tmp_modulecache/ + +# Swift Package Manager +.swiftpm/ +.build/ + +### Android ### +# Gradle files +.gradle/ +.gradle-home/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Xcode ### +## User settings +xcuserdata/ + +## Xcode 8 and earlier +*.xcscmblueprint +*.xccheckout + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) +hs_err_pid* + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/gradle,android,androidstudio,xcode,macos,windows,linux diff --git a/IrisCompanion/iris.xcodeproj/project.pbxproj b/IrisCompanion/iris.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c789cd3 --- /dev/null +++ b/IrisCompanion/iris.xcodeproj/project.pbxproj @@ -0,0 +1,582 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + F1779CAC2F0DAC9B009C6626 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F1779C962F0DAC9A009C6626 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F1779C9D2F0DAC9A009C6626; + remoteInfo = iris; + }; + F1779CB62F0DAC9B009C6626 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F1779C962F0DAC9A009C6626 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F1779C9D2F0DAC9A009C6626; + remoteInfo = iris; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + F1779C9E2F0DAC9A009C6626 /* iris.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iris.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F1779CAB2F0DAC9B009C6626 /* irisTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = irisTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F1779CB52F0DAC9B009C6626 /* irisUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = irisUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + F1779CDA2F0DB66F009C6626 /* Exceptions for "iris" folder in "iris" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F1779C9D2F0DAC9A009C6626 /* iris */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F1779CA02F0DAC9A009C6626 /* iris */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + F1779CDA2F0DB66F009C6626 /* Exceptions for "iris" folder in "iris" target */, + ); + path = iris; + sourceTree = ""; + }; + F1779CAE2F0DAC9B009C6626 /* irisTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = irisTests; + sourceTree = ""; + }; + F1779CB82F0DAC9B009C6626 /* irisUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = irisUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F1779C9B2F0DAC9A009C6626 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CA82F0DAC9B009C6626 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CB22F0DAC9B009C6626 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F1779C952F0DAC9A009C6626 = { + isa = PBXGroup; + children = ( + F1779CA02F0DAC9A009C6626 /* iris */, + F1779CAE2F0DAC9B009C6626 /* irisTests */, + F1779CB82F0DAC9B009C6626 /* irisUITests */, + F1779C9F2F0DAC9A009C6626 /* Products */, + ); + sourceTree = ""; + }; + F1779C9F2F0DAC9A009C6626 /* Products */ = { + isa = PBXGroup; + children = ( + F1779C9E2F0DAC9A009C6626 /* iris.app */, + F1779CAB2F0DAC9B009C6626 /* irisTests.xctest */, + F1779CB52F0DAC9B009C6626 /* irisUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F1779C9D2F0DAC9A009C6626 /* iris */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1779CBF2F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "iris" */; + buildPhases = ( + F1779C9A2F0DAC9A009C6626 /* Sources */, + F1779C9B2F0DAC9A009C6626 /* Frameworks */, + F1779C9C2F0DAC9A009C6626 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F1779CA02F0DAC9A009C6626 /* iris */, + ); + name = iris; + packageProductDependencies = ( + ); + productName = iris; + productReference = F1779C9E2F0DAC9A009C6626 /* iris.app */; + productType = "com.apple.product-type.application"; + }; + F1779CAA2F0DAC9B009C6626 /* irisTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1779CC22F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "irisTests" */; + buildPhases = ( + F1779CA72F0DAC9B009C6626 /* Sources */, + F1779CA82F0DAC9B009C6626 /* Frameworks */, + F1779CA92F0DAC9B009C6626 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F1779CAD2F0DAC9B009C6626 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F1779CAE2F0DAC9B009C6626 /* irisTests */, + ); + name = irisTests; + packageProductDependencies = ( + ); + productName = irisTests; + productReference = F1779CAB2F0DAC9B009C6626 /* irisTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F1779CB42F0DAC9B009C6626 /* irisUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1779CC52F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "irisUITests" */; + buildPhases = ( + F1779CB12F0DAC9B009C6626 /* Sources */, + F1779CB22F0DAC9B009C6626 /* Frameworks */, + F1779CB32F0DAC9B009C6626 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F1779CB72F0DAC9B009C6626 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F1779CB82F0DAC9B009C6626 /* irisUITests */, + ); + name = irisUITests; + packageProductDependencies = ( + ); + productName = irisUITests; + productReference = F1779CB52F0DAC9B009C6626 /* irisUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F1779C962F0DAC9A009C6626 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + F1779C9D2F0DAC9A009C6626 = { + CreatedOnToolsVersion = 16.4; + }; + F1779CAA2F0DAC9B009C6626 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F1779C9D2F0DAC9A009C6626; + }; + F1779CB42F0DAC9B009C6626 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = F1779C9D2F0DAC9A009C6626; + }; + }; + }; + buildConfigurationList = F1779C992F0DAC9A009C6626 /* Build configuration list for PBXProject "iris" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F1779C952F0DAC9A009C6626; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = F1779C9F2F0DAC9A009C6626 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F1779C9D2F0DAC9A009C6626 /* iris */, + F1779CAA2F0DAC9B009C6626 /* irisTests */, + F1779CB42F0DAC9B009C6626 /* irisUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F1779C9C2F0DAC9A009C6626 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CA92F0DAC9B009C6626 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CB32F0DAC9B009C6626 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F1779C9A2F0DAC9A009C6626 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CA72F0DAC9B009C6626 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1779CB12F0DAC9B009C6626 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F1779CAD2F0DAC9B009C6626 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F1779C9D2F0DAC9A009C6626 /* iris */; + targetProxy = F1779CAC2F0DAC9B009C6626 /* PBXContainerItemProxy */; + }; + F1779CB72F0DAC9B009C6626 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F1779C9D2F0DAC9A009C6626 /* iris */; + targetProxy = F1779CB62F0DAC9B009C6626 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F1779CBD2F0DAC9B009C6626 /* 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 = 5FG7YZ49ZA; + 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 = 18.5; + 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; + }; + F1779CBE2F0DAC9B009C6626 /* 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 = 5FG7YZ49ZA; + 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 = 18.5; + 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; + }; + F1779CC02F0DAC9B009C6626 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iris/iris.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iris/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass."; + 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"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.iris; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F1779CC12F0DAC9B009C6626 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iris/iris.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iris/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Allow Bluetooth to send context updates to Glass."; + 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"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.iris; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F1779CC32F0DAC9B009C6626 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.irisTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iris.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iris"; + }; + name = Debug; + }; + F1779CC42F0DAC9B009C6626 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.irisTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iris.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iris"; + }; + name = Release; + }; + F1779CC62F0DAC9B009C6626 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.irisUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = iris; + }; + name = Debug; + }; + F1779CC72F0DAC9B009C6626 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5FG7YZ49ZA; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.nym.irisUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = iris; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F1779C992F0DAC9A009C6626 /* Build configuration list for PBXProject "iris" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1779CBD2F0DAC9B009C6626 /* Debug */, + F1779CBE2F0DAC9B009C6626 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1779CBF2F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "iris" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1779CC02F0DAC9B009C6626 /* Debug */, + F1779CC12F0DAC9B009C6626 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1779CC22F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "irisTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1779CC32F0DAC9B009C6626 /* Debug */, + F1779CC42F0DAC9B009C6626 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1779CC52F0DAC9B009C6626 /* Build configuration list for PBXNativeTarget "irisUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1779CC62F0DAC9B009C6626 /* Debug */, + F1779CC72F0DAC9B009C6626 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F1779C962F0DAC9A009C6626 /* Project object */; +} diff --git a/IrisCompanion/iris.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/IrisCompanion/iris.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/IrisCompanion/iris.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/IrisCompanion/iris/Assets.xcassets/AccentColor.colorset/Contents.json b/IrisCompanion/iris/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/IrisCompanion/iris/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IrisCompanion/iris/Assets.xcassets/AppIcon.appiconset/Contents.json b/IrisCompanion/iris/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/IrisCompanion/iris/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IrisCompanion/iris/Assets.xcassets/Contents.json b/IrisCompanion/iris/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/IrisCompanion/iris/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift b/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift new file mode 100644 index 0000000..edd2f3a --- /dev/null +++ b/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift @@ -0,0 +1,431 @@ +// +// BlePeripheralManager.swift +// iris +// +// Created by Codex. +// + +import CoreBluetooth +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +final class BlePeripheralManager: NSObject, ObservableObject { + static let serviceUUID = CBUUID(string: "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D") + static let feedTxUUID = CBUUID(string: "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D") + static let controlRxUUID = CBUUID(string: "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D") + + @Published private(set) var bluetoothState: CBManagerState = .unknown + @Published var advertisingEnabled: Bool = true + @Published private(set) var isAdvertising: Bool = false + @Published private(set) var isSubscribed: Bool = false + @Published private(set) var subscribedCount: Int = 0 + @Published private(set) var lastMsgIdSent: UInt32 = 0 + @Published private(set) var lastPingAt: Date? = nil + @Published private(set) var lastCommand: String? = nil + @Published private(set) var notifyQueueDepth: Int = 0 + @Published private(set) var droppedNotifyPackets: Int = 0 + @Published private(set) var lastNotifyAt: Date? = nil + @Published private(set) var lastDataAt: Date? = nil + + private let queue = DispatchQueue(label: "iris.ble.peripheral.queue") + private lazy var peripheral = CBPeripheralManager(delegate: self, queue: queue) + + private var service: CBMutableService? + private var feedTx: CBMutableCharacteristic? + private var controlRx: CBMutableCharacteristic? + + private var subscribedCentralIds = Set() + private var centralMaxUpdateLength: [UUID: Int] = [:] + private var lastReadValue: Data = Data() + + private struct PendingMessage { + let msgId: UInt32 + let msgType: UInt8 + let payload: Data + let maxPayloadLen: Int + var nextChunkIndex: Int + let chunkCount: Int + + var remainingChunks: Int { max(0, chunkCount - nextChunkIndex) } + + func remainingBytesApprox(headerLen: Int) -> Int { + guard maxPayloadLen > 0 else { return remainingChunks * headerLen } + let remainingPayload = max(0, payload.count - (nextChunkIndex * maxPayloadLen)) + return remainingPayload + (remainingChunks * headerLen) + } + } + + private var pendingMessages: [PendingMessage] = [] + private var pingTimer: DispatchSourceTimer? + + private var msgId: UInt32 = 0 + var onFirstSubscribe: (() -> Void)? = nil + var onControlCommand: ((String) -> Void)? = nil + + private let packetHeaderLen = 9 + private let maxQueuedNotifyPackets = 20_000 + private let maxQueuedNotifyBytesApprox = 8_000_000 + + override init() { + super.init() + _ = peripheral + } + + func start() { + queue.async { [weak self] in + self?.applyAdvertisingPolicy() + } + } + + func stop() { + queue.async { [weak self] in + self?.publish { self?.advertisingEnabled = false } + self?.stopAdvertising() + self?.stopPing() + } + } + + func sendOpaque(_ payload: Data, msgType: UInt8 = 1) { + queue.async { [weak self] in + self?.sendMessage(msgType: msgType, payload: payload) + } + } + + func copyUUIDsToPasteboard() { + let text = """ + SERVICE_UUID=\(Self.serviceUUID.uuidString) + FEED_TX_UUID=\(Self.feedTxUUID.uuidString) + CONTROL_RX_UUID=\(Self.controlRxUUID.uuidString) + """ +#if canImport(UIKit) + DispatchQueue.main.async { + UIPasteboard.general.string = text + } +#endif + } + + private func ensureService() { + guard peripheral.state == .poweredOn else { return } + guard service == nil else { return } + + peripheral.removeAllServices() + + let feedTx = CBMutableCharacteristic( + type: Self.feedTxUUID, + properties: [.notify, .read], + value: nil, + permissions: [.readable] + ) + let controlRx = CBMutableCharacteristic( + type: Self.controlRxUUID, + properties: [.writeWithoutResponse, .write], + value: nil, + permissions: [.writeable] + ) + let service = CBMutableService(type: Self.serviceUUID, primary: true) + service.characteristics = [feedTx, controlRx] + self.service = service + self.feedTx = feedTx + self.controlRx = controlRx + peripheral.add(service) + } + + private func applyAdvertisingPolicy() { + ensureService() + guard peripheral.state == .poweredOn else { + stopAdvertising() + return + } + if advertisingEnabled, subscribedCentralIds.isEmpty { + startAdvertising() + } else { + stopAdvertising() + } + } + + private func startAdvertising() { + guard !isAdvertising else { return } + peripheral.startAdvertising([ + CBAdvertisementDataLocalNameKey: "GlassNow", + CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID], + ]) + publish { self.isAdvertising = true } + } + + private func stopAdvertising() { + guard isAdvertising else { return } + peripheral.stopAdvertising() + publish { self.isAdvertising = false } + } + + private func startPing() { + guard pingTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in + self?.sendPing() + } + timer.resume() + pingTimer = timer + } + + private func stopPing() { + pingTimer?.cancel() + pingTimer = nil + } + + private func sendPing() { + sendMessage(msgType: 2, payload: Data()) + publish { self.lastPingAt = Date() } + } + + private func sendMessage(msgType: UInt8, payload: Data) { + guard let feedTx = feedTx else { return } + guard !subscribedCentralIds.isEmpty else { return } + + lastReadValue = payload.trimmedTrailingWhitespace() + + msgId &+= 1 + publish { self.lastMsgIdSent = self.msgId } + + let maxLen = currentMaxUpdateValueLength() + guard maxLen > packetHeaderLen else { return } + let maxPayloadLen = max(1, maxLen - packetHeaderLen) + let totalPayloadLen = lastReadValue.count + let chunkCount = max(1, Int(ceil(Double(totalPayloadLen) / Double(maxPayloadLen)))) + guard chunkCount <= Int(UInt16.max) else { return } + + flushPendingMessages() + + var message = PendingMessage( + msgId: msgId, + msgType: msgType, + payload: lastReadValue, + maxPayloadLen: maxPayloadLen, + nextChunkIndex: 0, + chunkCount: chunkCount + ) + + if !pendingMessages.isEmpty { + enqueuePendingMessage(message) + return + } + + if !sendPendingMessage(&message, characteristic: feedTx) { + pendingMessages.append(message) + enforceNotifyQueueLimits() + } + if msgType != 2 { + publish { self.lastDataAt = Date() } + } + publishNotifyQueueDepth() + } + + private func buildPacket(msgId: UInt32, msgType: UInt8, chunkIndex: UInt16, chunkCount: UInt16, payloadSlice: Data.SubSequence) -> Data { + var data = Data() + data.reserveCapacity(packetHeaderLen + payloadSlice.count) + data.appendLE(msgId) + data.append(msgType) + data.appendLE(chunkIndex) + data.appendLE(chunkCount) + data.append(contentsOf: payloadSlice) + return data + } + + private func sendPendingMessage(_ message: inout PendingMessage, characteristic: CBMutableCharacteristic) -> Bool { + while message.nextChunkIndex < message.chunkCount { + let start = message.nextChunkIndex * message.maxPayloadLen + let end = min(start + message.maxPayloadLen, message.payload.count) + let packet = buildPacket( + msgId: message.msgId, + msgType: message.msgType, + chunkIndex: UInt16(message.nextChunkIndex), + chunkCount: UInt16(message.chunkCount), + payloadSlice: message.payload[start.. Int { + pendingMessages.reduce(0) { $0 + $1.remainingBytesApprox(headerLen: packetHeaderLen) } + } + + private func enforceNotifyQueueLimits() { + var dropped = 0 + while !pendingMessages.isEmpty { + let packetCount = pendingMessages.reduce(0) { $0 + $1.remainingChunks } + let bytesApprox = queuedNotifyBytesApprox() + if packetCount <= maxQueuedNotifyPackets, bytesApprox <= maxQueuedNotifyBytesApprox { + break + } + let removed = pendingMessages.removeLast() + dropped += removed.remainingChunks + } + if dropped > 0 { + publish { self.droppedNotifyPackets += dropped } + } + } + + private func publish(_ block: @escaping () -> Void) { + DispatchQueue.main.async(execute: block) + } + + private func currentMaxUpdateValueLength() -> Int { + let lengths = subscribedCentralIds.compactMap { centralMaxUpdateLength[$0] }.filter { $0 > 0 } + if let minLen = lengths.min() { + return minLen + } + return 180 + } +} + +extension BlePeripheralManager: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + queue.async { [weak self] in + guard let self = self else { return } + self.publish { self.bluetoothState = peripheral.state } + if peripheral.state != .poweredOn { + self.subscribedCentralIds.removeAll() + self.centralMaxUpdateLength.removeAll() + self.pendingMessages.removeAll() + self.stopPing() + self.stopAdvertising() + self.publish { + self.isSubscribed = false + self.subscribedCount = 0 + self.notifyQueueDepth = 0 + } + self.service = nil + self.feedTx = nil + self.controlRx = nil + return + } + self.ensureService() + self.applyAdvertisingPolicy() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + queue.async { [weak self] in + self?.applyAdvertisingPolicy() + } + } + + func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + publish { self.isAdvertising = (error == nil) } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard characteristic.uuid == Self.feedTxUUID else { return } + queue.async { [weak self] in + guard let self = self else { return } + let wasEmpty = self.subscribedCentralIds.isEmpty + self.subscribedCentralIds.insert(central.identifier) + self.centralMaxUpdateLength[central.identifier] = central.maximumUpdateValueLength + self.publishNotifyQueueDepth() + self.publish { + self.isSubscribed = true + self.subscribedCount = self.subscribedCentralIds.count + } + self.applyAdvertisingPolicy() + self.startPing() + if wasEmpty { + self.onFirstSubscribe?() + } + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + guard characteristic.uuid == Self.feedTxUUID else { return } + queue.async { [weak self] in + guard let self = self else { return } + self.subscribedCentralIds.remove(central.identifier) + self.centralMaxUpdateLength[central.identifier] = nil + self.publishNotifyQueueDepth() + self.publish { + self.subscribedCount = self.subscribedCentralIds.count + self.isSubscribed = !self.subscribedCentralIds.isEmpty + } + if self.subscribedCentralIds.isEmpty { + self.stopPing() + } + self.applyAdvertisingPolicy() + } + } + + func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + queue.async { [weak self] in + self?.flushPendingMessages() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + guard request.characteristic.uuid == Self.feedTxUUID else { + peripheral.respond(to: request, withResult: .requestNotSupported) + return + } + let maxLen = max(0, request.central.maximumUpdateValueLength) + request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue + peripheral.respond(to: request, withResult: .success) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + for request in requests { + guard request.characteristic.uuid == Self.controlRxUUID else { + continue + } + let command = String(data: request.value ?? Data(), encoding: .utf8) ?? "" + publish { self.lastCommand = command } + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + self.onControlCommand?(trimmed) + peripheral.respond(to: request, withResult: .success) + } + } +} + +private extension Data { + mutating func appendLE(_ value: UInt32) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + mutating func appendLE(_ value: UInt16) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + // `trimmedTrailingWhitespace()` lives in `iris/Utils/DataTrimming.swift`. +} diff --git a/IrisCompanion/iris/ContentView.swift b/IrisCompanion/iris/ContentView.swift new file mode 100644 index 0000000..a89accf --- /dev/null +++ b/IrisCompanion/iris/ContentView.swift @@ -0,0 +1,36 @@ +// +// ContentView.swift +// iris +// +// Created by Kenneth on 06/01/2026. +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var orchestrator: ContextOrchestrator + + var body: some View { + TabView { + BleStatusView() + .tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") } + OrchestratorView() + .tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") } + } + .onAppear { orchestrator.start() } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 16.0, *) { + let ble = BlePeripheralManager() + let orchestrator = ContextOrchestrator(ble: ble) + ContentView() + .environmentObject(ble) + .environmentObject(orchestrator) + } else { + ContentView() + } + } +} diff --git a/IrisCompanion/iris/DataSources/CalendarDataSource.swift b/IrisCompanion/iris/DataSources/CalendarDataSource.swift new file mode 100644 index 0000000..e4615b1 --- /dev/null +++ b/IrisCompanion/iris/DataSources/CalendarDataSource.swift @@ -0,0 +1,200 @@ +// +// CalendarDataSource.swift +// iris +// +// Created by Codex. +// + +import EventKit +import Foundation + +struct CalendarDataSourceConfig: Sendable { + var lookaheadSec: Int = 2 * 60 * 60 + var soonWindowSec: Int = 30 * 60 + var maxCandidates: Int = 3 + var includeAllDay: Bool = false + var includeDeclined: Bool = false + + init() {} +} + +final class CalendarDataSource { + struct CandidatesResult: Sendable { + let candidates: [Candidate] + let error: String? + let diagnostics: [String: String] + } + + private let store: EKEventStore + private let config: CalendarDataSourceConfig + + init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) { + self.store = store + self.config = config + } + + func candidatesWithDiagnostics(now: Int) async -> CandidatesResult { + var diagnostics: [String: String] = [ + "now": String(now), + "lookahead_sec": String(config.lookaheadSec), + "soon_window_sec": String(config.soonWindowSec), + ] + + let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) + let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec)) + + let auth = calendarAuthorizationStatusString() + diagnostics["auth"] = auth + + let accessGranted = await ensureCalendarAccess() + diagnostics["access_granted"] = accessGranted ? "true" : "false" + + guard accessGranted else { + return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics) + } + + let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil) + let events = store.events(matching: predicate) + + diagnostics["events_matched"] = String(events.count) + + let filtered = events + .filter { shouldInclude(event: $0) } + .sorted { $0.startDate < $1.startDate } + + diagnostics["events_filtered"] = String(filtered.count) + + let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate) + diagnostics["candidates"] = String(candidates.count) + + return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics) + } + + private func shouldInclude(event: EKEvent) -> Bool { + if event.isAllDay, !config.includeAllDay { + return false + } + if !config.includeDeclined { + // `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability. + // Many calendars won’t provide participants; keep it simple for Phase 1. + if event.status == .canceled { + return false + } + } + return true + } + + private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] { + var results: [Candidate] = [] + results.reserveCapacity(min(config.maxCandidates, events.count)) + + for event in events { + if results.count >= config.maxCandidates { break } + + guard let start = event.startDate, let end = event.endDate else { continue } + let isOngoing = start <= nowDate && end > nowDate + let startsInSec = Int(start.timeIntervalSince(nowDate)) + + if !isOngoing, startsInSec > config.soonWindowSec { + continue + } + + let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming") + .truncated(maxLength: TextConstraints.titleMax) + + let subtitle = subtitleText(event: event, nowDate: nowDate) + .truncated(maxLength: TextConstraints.subtitleMax) + + let confidence: Double = isOngoing ? 0.9 : 0.7 + let ttl = ttlSec(end: end, nowDate: nowDate) + + let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))" + + results.append( + Candidate( + id: id, + type: .info, + title: title, + subtitle: subtitle, + confidence: confidence, + createdAt: now, + ttlSec: ttl, + metadata: [ + "source": "eventkit", + "calendar": event.calendar.title, + "start": String(Int(start.timeIntervalSince1970)), + "end": String(Int(end.timeIntervalSince1970)), + "all_day": event.isAllDay ? "true" : "false", + "location": event.location ?? "", + ] + ) + ) + } + + return results + } + + private func ttlSec(end: Date, nowDate: Date) -> Int { + let ttl = Int(end.timeIntervalSince(nowDate)) + // Keep the candidate alive until it ends, but cap at 2h and floor at 60s. + return min(max(ttl, 60), 2 * 60 * 60) + } + + private func subtitleText(event: EKEvent, nowDate: Date) -> String { + guard let start = event.startDate, let end = event.endDate else { return "" } + let isOngoing = start <= nowDate && end > nowDate + + if isOngoing { + let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0))) + if let loc = event.location, !loc.isEmpty { + return "\(loc) • \(remainingMin)m left" + } + return "\(remainingMin)m left" + } + + let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0))) + if let loc = event.location, !loc.isEmpty { + return "In \(minutes)m • \(loc)" + } + return "In \(minutes)m" + } + + private func calendarAuthorizationStatusString() -> String { + if #available(iOS 17.0, *) { + return String(describing: EKEventStore.authorizationStatus(for: .event)) + } + return String(describing: EKEventStore.authorizationStatus(for: .event)) + } + + private func ensureCalendarAccess() async -> Bool { + let status = EKEventStore.authorizationStatus(for: .event) + + switch status { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + return await requestAccess() + @unknown default: + return false + } + } + + private func requestAccess() async -> Bool { + await MainActor.run { + // Ensure the permission prompt is allowed to present. + } + return await withCheckedContinuation { continuation in + if #available(iOS 17.0, *) { + store.requestFullAccessToEvents { granted, _ in + continuation.resume(returning: granted) + } + } else { + store.requestAccess(to: .event) { granted, _ in + continuation.resume(returning: granted) + } + } + } + } +} diff --git a/IrisCompanion/iris/DataSources/POIDataSource.swift b/IrisCompanion/iris/DataSources/POIDataSource.swift new file mode 100644 index 0000000..5a98d96 --- /dev/null +++ b/IrisCompanion/iris/DataSources/POIDataSource.swift @@ -0,0 +1,33 @@ +// +// POIDataSource.swift +// iris +// +// Created by Codex. +// + +import CoreLocation +import Foundation + +struct POIDataSourceConfig: Sendable { + var maxCandidates: Int = 3 + init() {} +} + +/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs. +final class POIDataSource { + private let config: POIDataSourceConfig + + init(config: POIDataSourceConfig = .init()) { + self.config = config + } + + func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] { + // Phase 1 stub: return nothing. + // (Still async/throws so the orchestrator can treat it uniformly with real implementations later.) + _ = config + _ = location + _ = now + return [] + } +} + diff --git a/IrisCompanion/iris/DataSources/WeatherDataSource.swift b/IrisCompanion/iris/DataSources/WeatherDataSource.swift new file mode 100644 index 0000000..8b8ca27 --- /dev/null +++ b/IrisCompanion/iris/DataSources/WeatherDataSource.swift @@ -0,0 +1,334 @@ +// +// WeatherDataSource.swift +// iris +// +// Created by Codex. +// + +import CoreLocation +import Foundation +import WeatherKit + +struct WeatherAlertConfig: Sendable { + var rainLookaheadSec: Int = 20 * 60 + var precipitationChanceThreshold: Double = 0.5 + var gustThresholdMps: Double? = nil + + var rainTTL: Int = 1800 + var windTTL: Int = 3600 + + init() {} +} + +protocol WeatherWarningProviding: Sendable { + func warningCandidates(location: CLLocation, now: Int) -> [Candidate] +} + +struct NoopWeatherWarningProvider: WeatherWarningProviding { + func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] } +} + +/// Loads mock warning candidates from a local JSON file. +/// +/// File format: +/// [ +/// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } } +/// ] +struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable { + struct MockWarning: Codable { + let id: String + let title: String + let subtitle: String + let ttlSec: Int? + let confidence: Double? + let meta: [String: String]? + + enum CodingKeys: String, CodingKey { + case id + case title + case subtitle + case ttlSec = "ttl_sec" + case confidence + case meta + } + } + + let url: URL? + + init(url: URL?) { + self.url = url + } + + func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { + guard let url else { return [] } + guard let data = try? Data(contentsOf: url) else { return [] } + guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] } + return items.map { item in + Candidate( + id: item.id, + type: .weatherWarning, + title: item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0), + createdAt: now, + ttlSec: max(1, item.ttlSec ?? 3600), + metadata: item.meta + ) + } + } +} + +@available(iOS 16.0, *) +final class WeatherDataSource { + private let service: WeatherService + private let config: WeatherAlertConfig + private let warningProvider: WeatherWarningProviding + + init(service: WeatherService = .shared, + config: WeatherAlertConfig = .init(), + warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider( + url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") + )) { + self.service = service + self.config = config + self.warningProvider = warningProvider + } + + /// Returns alert candidates derived from WeatherKit forecasts. + func candidates(for location: CLLocation, now: Int) async -> [Candidate] { + let result = await candidatesWithDiagnostics(for: location, now: now) + return result.candidates + } + + struct CandidatesResult: Sendable { + let candidates: [Candidate] + let weatherKitError: String? + let diagnostics: [String: String] + } + + func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult { + var results: [Candidate] = [] + var errorString: String? = nil + var diagnostics: [String: String] = [ + "lat": String(format: "%.5f", location.coordinate.latitude), + "lon": String(format: "%.5f", location.coordinate.longitude), + "now": String(now), + "rain_lookahead_sec": String(config.rainLookaheadSec), + "precip_chance_threshold": String(format: "%.2f", config.precipitationChanceThreshold), + "gust_threshold_mps": config.gustThresholdMps.map { String(format: "%.2f", $0) } ?? "nil", + ] + + do { + let weather = try await service.weather(for: location) + results.append(currentConditionsCandidate(weather: weather, location: location, now: now)) + diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil" + diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count) + if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value { + diagnostics["current_gust_mps"] = String(format: "%.2f", gust) + } else { + diagnostics["current_gust_mps"] = "nil" + } + + results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now)) + results.append(contentsOf: windCandidates(weather: weather, location: location, now: now)) + diagnostics["candidates_weatherkit"] = String(results.count) + + diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new } + } catch { + errorString = String(describing: error) + diagnostics["weatherkit_error"] = errorString + } + + results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now)) + diagnostics["candidates_total"] = String(results.count) + return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics) + } + + private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate { + let tempC = weather.currentWeather.temperature.converted(to: .celsius).value + let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value + let tempInt = Int(tempC.rounded()) + let feelsInt = Int(feelsC.rounded()) + let cond = weather.currentWeather.condition.description + let conditionEnum = weather.currentWeather.condition + + let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax) + let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax) + + return Candidate( + id: "wx:now:\(now / 60)", + type: .currentWeather, + title: title, + subtitle: subtitle, + confidence: 0.8, + createdAt: now, + ttlSec: 1800, + condition: conditionEnum, + metadata: [ + "source": "weatherkit_current", + "lat": String(format: "%.5f", location.coordinate.latitude), + "lon": String(format: "%.5f", location.coordinate.longitude), + ] + ) + } + + private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { + let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) + let lookahead = TimeInterval(config.rainLookaheadSec) + + if let minuteForecast = weather.minuteForecast { + if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) { + return [ + Candidate( + id: "wx:rain:\(Int(start.timeIntervalSince1970))", + type: .weatherAlert, + title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), + subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax), + confidence: 0.9, + createdAt: now, + ttlSec: config.rainTTL, + metadata: [ + "source": "weatherkit_minutely", + "lat": String(format: "%.5f", location.coordinate.latitude), + "lon": String(format: "%.5f", location.coordinate.longitude), + ] + ) + ] + } + return [] + } + + if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) { + return [ + Candidate( + id: "wx:rain:\(Int(start.timeIntervalSince1970))", + type: .weatherAlert, + title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), + subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax), + confidence: 0.6, + createdAt: now, + ttlSec: config.rainTTL, + metadata: [ + "source": "weatherkit_hourly_approx", + "lat": String(format: "%.5f", location.coordinate.latitude), + "lon": String(format: "%.5f", location.coordinate.longitude), + ] + ) + ] + } + + return [] + } + + private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { + guard let gustThreshold = config.gustThresholdMps else { return [] } + guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] } + guard gust >= gustThreshold else { return [] } + + let mph = Int((gust * 2.236936).rounded()) + let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax) + return [ + Candidate( + id: "wx:wind:\(now):\(Int(gustThreshold * 10))", + type: .weatherAlert, + title: title, + subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax), + confidence: 0.8, + createdAt: now, + ttlSec: config.windTTL, + metadata: [ + "source": "weatherkit_current", + "gust_mps": String(format: "%.2f", gust), + "threshold_mps": String(format: "%.2f", gustThreshold), + "lat": String(format: "%.5f", location.coordinate.latitude), + "lon": String(format: "%.5f", location.coordinate.longitude), + ] + ) + ] + } + + private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? { + for minute in minutes { + guard minute.date >= now else { continue } + let dt = minute.date.timeIntervalSince(now) + guard dt <= lookahead else { break } + let chance = minute.precipitationChance + let isRainy = minute.precipitation == .rain || minute.precipitation == .mixed + if isRainy, chance >= config.precipitationChanceThreshold { + return minute.date + } + } + return nil + } + + private func firstRainStartHourly(_ hours: [HourWeather], now: Date, within lookahead: TimeInterval) -> Date? { + for hour in hours { + guard hour.date >= now else { continue } + let dt = hour.date.timeIntervalSince(now) + guard dt <= lookahead else { break } + let chance = hour.precipitationChance + let isRainy = hour.precipitation == .rain || hour.precipitation == .mixed + if isRainy, chance >= config.precipitationChanceThreshold { + return hour.date + } + } + return nil + } + + private func rainTitle(start: Date, now: Date) -> String { + let minutes = max(0, Int(((start.timeIntervalSince(now)) / 60.0).rounded())) + if minutes <= 0 { return "Rain now" } + return "Rain in ~\(minutes) min" + } + + private func rainDiagnostics(weather: Weather, now: Int) -> [String: String] { + let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) + let lookahead = TimeInterval(config.rainLookaheadSec) + + if let minuteForecast = weather.minuteForecast { + var bestChance: Double = 0 + var bestType: String = "none" + var firstAnyPrecipOffsetSec: Int? = nil + for minute in minuteForecast.forecast { + guard minute.date >= nowDate else { continue } + let dt = minute.date.timeIntervalSince(nowDate) + guard dt <= lookahead else { break } + if minute.precipitation != .none, firstAnyPrecipOffsetSec == nil { + firstAnyPrecipOffsetSec = Int(dt.rounded()) + } + if minute.precipitationChance > bestChance { + bestChance = minute.precipitationChance + bestType = minute.precipitation.rawValue + } + } + return [ + "rain_resolution": "minutely", + "rain_best_chance": String(format: "%.2f", bestChance), + "rain_best_type": bestType, + "rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil", + ] + } + + var bestChance: Double = 0 + var bestType: String = "none" + var firstAnyPrecipOffsetSec: Int? = nil + for hour in weather.hourlyForecast.forecast { + guard hour.date >= nowDate else { continue } + let dt = hour.date.timeIntervalSince(nowDate) + guard dt <= lookahead else { break } + if hour.precipitation != .none, firstAnyPrecipOffsetSec == nil { + firstAnyPrecipOffsetSec = Int(dt.rounded()) + } + if hour.precipitationChance > bestChance { + bestChance = hour.precipitationChance + bestType = hour.precipitation.rawValue + } + } + return [ + "rain_resolution": "hourly", + "rain_best_chance": String(format: "%.2f", bestChance), + "rain_best_type": bestType, + "rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil", + ] + } +} diff --git a/IrisCompanion/iris/Info.plist b/IrisCompanion/iris/Info.plist new file mode 100644 index 0000000..7a0ae51 --- /dev/null +++ b/IrisCompanion/iris/Info.plist @@ -0,0 +1,24 @@ + + + + + NSBluetoothAlwaysUsageDescription + Allow Bluetooth to send context updates to Glass. + NSCalendarsUsageDescription + Allow calendar access to show upcoming events on Glass. + NSCalendarsFullAccessUsageDescription + Allow full calendar access to show upcoming events on Glass. + NSAppleMusicUsageDescription + Allow access to your Now Playing information to show music on Glass. + NSLocationWhenInUseUsageDescription + Allow location access to compute context cards for Glass. + NSLocationAlwaysAndWhenInUseUsageDescription + Allow background location access to keep Glass context updated while moving. + UIBackgroundModes + + bluetooth-peripheral + bluetooth-central + location + + + diff --git a/IrisCompanion/iris/Models/Candidate.swift b/IrisCompanion/iris/Models/Candidate.swift new file mode 100644 index 0000000..0ac0cc5 --- /dev/null +++ b/IrisCompanion/iris/Models/Candidate.swift @@ -0,0 +1,61 @@ +// +// Candidate.swift +// iris +// +// Created by Codex. +// + +import Foundation +import WeatherKit + +struct Candidate: Codable, Equatable { + let id: String + let type: WinnerType + let title: String + let subtitle: String + let confidence: Double + let createdAt: Int + let ttlSec: Int + let condition: WeatherKit.WeatherCondition? + let metadata: [String: String]? + + enum CodingKeys: String, CodingKey { + case id + case type + case title + case subtitle + case confidence + case createdAt + case ttlSec + case condition + case metadata + } + + init(id: String, + type: WinnerType, + title: String, + subtitle: String, + confidence: Double, + createdAt: Int, + ttlSec: Int, + condition: WeatherKit.WeatherCondition? = nil, + metadata: [String: String]? = nil) { + self.id = id + self.type = type + self.title = title + self.subtitle = subtitle + self.confidence = confidence + self.createdAt = createdAt + self.ttlSec = ttlSec + self.condition = condition + self.metadata = metadata + } + + func isExpired(at now: Int) -> Bool { + if ttlSec > 0 { + createdAt + ttlSec <= now + } else { + true + } + } +} diff --git a/IrisCompanion/iris/Models/FeedEnvelope.swift b/IrisCompanion/iris/Models/FeedEnvelope.swift new file mode 100644 index 0000000..8bdbbc2 --- /dev/null +++ b/IrisCompanion/iris/Models/FeedEnvelope.swift @@ -0,0 +1,193 @@ +// +// FeedEnvelope.swift +// iris +// +// Created by Codex. +// + +import Foundation +import WeatherKit + +struct FeedEnvelope: Codable, Equatable { + let schema: Int + let generatedAt: Int + let feed: [FeedCard] + let meta: FeedMeta + + enum CodingKeys: String, CodingKey { + case schema + case generatedAt = "generated_at" + case feed + case meta + } +} + +struct FeedCard: Codable, Equatable { + enum Bucket: String, Codable { + case rightNow = "RIGHT_NOW" + case fyi = "FYI" + } + + let id: String + let type: WinnerType + let title: String + let subtitle: String + let priority: Double + let ttlSec: Int + let condition: WeatherKit.WeatherCondition? + let bucket: Bucket + let actions: [String] + + enum CodingKeys: String, CodingKey { + case id + case type + case title + case subtitle + case priority + case ttlSec = "ttl_sec" + case condition + case bucket + case actions + } + + init(id: String, + type: WinnerType, + title: String, + subtitle: String, + priority: Double, + ttlSec: Int, + condition: WeatherKit.WeatherCondition? = nil, + bucket: Bucket, + actions: [String]) { + self.id = id + self.type = type + self.title = title + self.subtitle = subtitle + self.priority = priority + self.ttlSec = ttlSec + self.condition = condition + self.bucket = bucket + self.actions = actions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + type = try container.decode(WinnerType.self, forKey: .type) + title = try container.decode(String.self, forKey: .title) + subtitle = try container.decode(String.self, forKey: .subtitle) + priority = try container.decode(Double.self, forKey: .priority) + ttlSec = try container.decode(Int.self, forKey: .ttlSec) + bucket = try container.decode(Bucket.self, forKey: .bucket) + actions = try container.decode([String].self, forKey: .actions) + + if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) { + condition = WeatherKit.WeatherCondition.irisDecode(encoded) + } else { + condition = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(priority, forKey: .priority) + try container.encode(ttlSec, forKey: .ttlSec) + try container.encode(bucket, forKey: .bucket) + try container.encode(actions, forKey: .actions) + if let condition { + try container.encode(condition.irisScreamingCase(), forKey: .condition) + } + } +} + +struct FeedMeta: Codable, Equatable { + let winnerId: String + let unreadCount: Int + + enum CodingKeys: String, CodingKey { + case winnerId = "winner_id" + case unreadCount = "unread_count" + } +} + +extension FeedEnvelope { + static func fromWinnerAndWeather(now: Int, winner: WinnerEnvelope, weather: Candidate?) -> FeedEnvelope { + var cards: [FeedCard] = [] + + let winnerCard = FeedCard( + id: winner.winner.id, + type: winner.winner.type, + title: winner.winner.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: winner.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(winner.winner.priority, 0.0), 1.0), + ttlSec: max(1, winner.winner.ttlSec), + condition: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + cards.append(winnerCard) + + if let weather, weather.id != winner.winner.id { + let weatherCard = FeedCard( + id: weather.id, + type: weather.type, + title: weather.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: weather.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(weather.confidence, 0.0), 1.0), + ttlSec: max(1, weather.ttlSec), + condition: weather.condition, + bucket: .fyi, + actions: ["DISMISS"] + ) + cards.append(weatherCard) + } + + return FeedEnvelope( + schema: 1, + generatedAt: now, + feed: cards, + meta: FeedMeta(winnerId: winner.winner.id, unreadCount: cards.count) + ) + } +} + +extension FeedEnvelope { + static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope { + let card = FeedCard( + id: "quiet-000", + type: .allQuiet, + title: "All Quiet", + subtitle: "No urgent updates", + priority: 0.05, + ttlSec: 300, + condition: nil, + bucket: .rightNow, + actions: ["DISMISS"] + ) + return FeedEnvelope(schema: 1, generatedAt: now, feed: [card], meta: FeedMeta(winnerId: card.id, unreadCount: 1)) + } + + func winnerCard() -> FeedCard? { + feed.first(where: { $0.id == meta.winnerId }) ?? feed.first + } + + func asWinnerEnvelope() -> WinnerEnvelope { + let now = generatedAt + guard let winnerCard = winnerCard() else { + return WinnerEnvelope.allQuiet(now: now) + } + let winner = Winner( + id: winnerCard.id, + type: winnerCard.type, + title: winnerCard.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: winnerCard.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(winnerCard.priority, 0.0), 1.0), + ttlSec: max(1, winnerCard.ttlSec) + ) + return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: nil) + } +} diff --git a/IrisCompanion/iris/Models/FeedStore.swift b/IrisCompanion/iris/Models/FeedStore.swift new file mode 100644 index 0000000..6f66480 --- /dev/null +++ b/IrisCompanion/iris/Models/FeedStore.swift @@ -0,0 +1,192 @@ +// +// FeedStore.swift +// iris +// +// Created by Codex. +// + +import Foundation + +final class FeedStore { + struct CardKey: Hashable, Codable { + let id: String + let type: WinnerType + } + + struct CardState: Codable, Equatable { + var lastShownAt: Int? + var dismissedUntil: Int? + var snoozedUntil: Int? + } + + private struct Persisted: Codable { + var feed: FeedEnvelope? + var states: [String: CardState] + } + + private let queue = DispatchQueue(label: "iris.feedstore.queue") + private let fileURL: URL + private var cachedFeed: FeedEnvelope? + private var states: [String: CardState] = [:] + + init(filename: String = "feed_store_v1.json") { + self.fileURL = Self.defaultFileURL(filename: filename) + let persisted = Self.load(from: fileURL) + self.cachedFeed = persisted?.feed + self.states = persisted?.states ?? [:] + } + + func getFeed(now: Int = Int(Date().timeIntervalSince1970)) -> FeedEnvelope { + queue.sync { + guard let feed = cachedFeed else { + return FeedEnvelope.allQuiet(now: now, reason: "no_feed", source: "store") + } + let filtered = normalizedFeed(feed, now: now) + if filtered.feed.isEmpty { + return FeedEnvelope.allQuiet(now: now, reason: "expired_or_suppressed", source: "store") + } + return filtered + } + } + + func setFeed(_ feed: FeedEnvelope, now: Int = Int(Date().timeIntervalSince1970)) { + queue.sync { + let normalized = normalizedFeed(feed, now: now, applyingSuppression: false) + cachedFeed = normalized + markShown(feed: normalized, now: now) + save() + } + } + + func lastShownAt(candidateId: String) -> Int? { + queue.sync { + let matches = states.compactMap { (key, value) -> Int? in + guard key.hasSuffix("|" + candidateId) else { return nil } + return value.lastShownAt + } + return matches.max() + } + } + + func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool { + queue.sync { + let key = Self.keyString(id: id, type: type) + guard let state = states[key] else { return false } + if let until = state.dismissedUntil, until > now { return true } + if let until = state.snoozedUntil, until > now { return true } + return false + } + } + + func dismiss(id: String, type: WinnerType, until: Int? = nil) { + queue.sync { + let key = Self.keyString(id: id, type: type) + var state = states[key] ?? CardState() + state.dismissedUntil = until ?? Int.max + states[key] = state + save() + } + } + + func snooze(id: String, type: WinnerType, until: Int) { + queue.sync { + let key = Self.keyString(id: id, type: type) + var state = states[key] ?? CardState() + state.snoozedUntil = until + states[key] = state + save() + } + } + + func clearSuppression(id: String, type: WinnerType) { + queue.sync { + let key = Self.keyString(id: id, type: type) + var state = states[key] ?? CardState() + state.dismissedUntil = nil + state.snoozedUntil = nil + states[key] = state + save() + } + } + + private func markShown(feed: FeedEnvelope, now: Int) { + for card in feed.feed { + let key = Self.keyString(id: card.id, type: card.type) + var state = states[key] ?? CardState() + state.lastShownAt = now + states[key] = state + } + } + + private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope { + let normalizedCards = feed.feed.compactMap { card -> FeedCard? in + let ttl = max(1, card.ttlSec) + if feed.generatedAt + ttl <= now { return nil } + if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil } + return FeedCard( + id: card.id, + type: card.type, + title: card.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: card.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(card.priority, 0.0), 1.0), + ttlSec: ttl, + condition: card.condition, + bucket: card.bucket, + actions: card.actions + ) + } + + let winnerId = normalizedCards.first(where: { $0.id == feed.meta.winnerId })?.id ?? normalizedCards.first?.id ?? "quiet-000" + let normalized = FeedEnvelope( + schema: 1, + generatedAt: max(1, feed.generatedAt), + feed: normalizedCards, + meta: FeedMeta(winnerId: winnerId, unreadCount: normalizedCards.count) + ) + return normalized + } + + private func isSuppressedUnlocked(id: String, type: WinnerType, now: Int) -> Bool { + let key = Self.keyString(id: id, type: type) + guard let state = states[key] else { return false } + if let until = state.dismissedUntil, until > now { return true } + if let until = state.snoozedUntil, until > now { return true } + return false + } + + private func save() { + let persisted = Persisted(feed: cachedFeed, states: states) + Self.save(persisted, to: fileURL) + } + + private static func keyString(id: String, type: WinnerType) -> String { + "\(type.rawValue)|\(id)" + } + + private static func defaultFileURL(filename: String) -> URL { + let fm = FileManager.default + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory + let bundle = Bundle.main.bundleIdentifier ?? "iris" + return base + .appendingPathComponent(bundle, isDirectory: true) + .appendingPathComponent(filename, isDirectory: false) + } + + private static func load(from url: URL) -> Persisted? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(Persisted.self, from: data) + } + + private static func save(_ persisted: Persisted, to url: URL) { + do { + let fm = FileManager.default + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let data = try encoder.encode(persisted) + try data.write(to: url, options: [.atomic]) + } catch { + // Best-effort persistence. + } + } +} diff --git a/IrisCompanion/iris/Models/HeuristicRanker.swift b/IrisCompanion/iris/Models/HeuristicRanker.swift new file mode 100644 index 0000000..44162e4 --- /dev/null +++ b/IrisCompanion/iris/Models/HeuristicRanker.swift @@ -0,0 +1,81 @@ +// +// HeuristicRanker.swift +// iris +// +// Created by Codex. +// + +import Foundation + +struct UserContext: Equatable { + var isMoving: Bool + var city: String + + init(isMoving: Bool, city: String = "London") { + self.isMoving = isMoving + self.city = city + } +} + +final class HeuristicRanker { + private let nowProvider: () -> Int + private let lastShownAtProvider: (String) -> Int? + + init(now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) }, + lastShownAt: @escaping (String) -> Int? = { _ in nil }) { + self.nowProvider = now + self.lastShownAtProvider = lastShownAt + } + + func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner { + let currentTime = now ?? nowProvider() + + let valid = candidates + .filter { !$0.isExpired(at: currentTime) } + .filter { $0.confidence >= 0.0 } + + guard !valid.isEmpty else { + return WinnerEnvelope.allQuiet(now: currentTime).winner + } + + var best: (candidate: Candidate, score: Double)? + for candidate in valid { + let baseWeight = baseWeight(for: candidate.type) + var score = baseWeight * min(max(candidate.confidence, 0.0), 1.0) + if let shownAt = lastShownAtProvider(candidate.id), + currentTime - shownAt <= 2 * 60 * 60 { + score -= 0.4 + } + if best == nil || score > best!.score { + best = (candidate, score) + } + } + + guard let best else { + return WinnerEnvelope.allQuiet(now: currentTime).winner + } + + let priority = min(max(best.score, 0.0), 1.0) + return Winner( + id: best.candidate.id, + type: best.candidate.type, + title: best.candidate.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: best.candidate.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: priority, + ttlSec: max(1, best.candidate.ttlSec) + ) + } + + private func baseWeight(for type: WinnerType) -> Double { + switch type { + case .weatherWarning: return 1.0 + case .weatherAlert: return 0.9 + case .transit: return 0.75 + case .poiNearby: return 0.6 + case .info: return 0.4 + case .nowPlaying: return 0.25 + case .currentWeather: return 0.0 + case .allQuiet: return 0.0 + } + } +} diff --git a/IrisCompanion/iris/Models/WeatherKitConditionCoding.swift b/IrisCompanion/iris/Models/WeatherKitConditionCoding.swift new file mode 100644 index 0000000..10e7e06 --- /dev/null +++ b/IrisCompanion/iris/Models/WeatherKitConditionCoding.swift @@ -0,0 +1,50 @@ +// +// WeatherKitConditionCoding.swift +// iris +// +// Created by Codex. +// + +import Foundation +import WeatherKit + +extension WeatherKit.WeatherCondition { + func irisScreamingCase() -> String { + Self.upperSnake(from: rawValue) + } + + static func irisDecode(_ value: String) -> WeatherKit.WeatherCondition? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.uppercased() == "UNKNOWN" { return nil } + + if let direct = WeatherKit.WeatherCondition(rawValue: trimmed) { return direct } + + let lowerCamel = lowerCamel(fromScreamingOrSnake: trimmed) + if let mapped = WeatherKit.WeatherCondition(rawValue: lowerCamel) { return mapped } + + return nil + } + + private static func upperSnake(from value: String) -> String { + var out = "" + out.reserveCapacity(value.count + 8) + for scalar in value.unicodeScalars { + if CharacterSet.uppercaseLetters.contains(scalar), !out.isEmpty { + out.append("_") + } + out.append(String(scalar).uppercased()) + } + return out + } + + private static func lowerCamel(fromScreamingOrSnake value: String) -> String { + let parts = value + .split(separator: "_") + .map { $0.lowercased() } + guard let first = parts.first else { return value } + let rest = parts.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() } + return ([String(first)] + rest).joined() + } +} + diff --git a/IrisCompanion/iris/Models/Winner.swift b/IrisCompanion/iris/Models/Winner.swift new file mode 100644 index 0000000..fa55056 --- /dev/null +++ b/IrisCompanion/iris/Models/Winner.swift @@ -0,0 +1,37 @@ +// +// Winner.swift +// iris +// +// Created by Codex. +// + +import Foundation + +enum WinnerType: String, Codable, CaseIterable { + case weatherAlert = "WEATHER_ALERT" + case weatherWarning = "WEATHER_WARNING" + case transit = "TRANSIT" + case poiNearby = "POI_NEARBY" + case info = "INFO" + case nowPlaying = "NOW_PLAYING" + case currentWeather = "CURRENT_WEATHER" + case allQuiet = "ALL_QUIET" +} + +struct Winner: Codable, Equatable { + let id: String + let type: WinnerType + let title: String + let subtitle: String + let priority: Double + let ttlSec: Int + + enum CodingKeys: String, CodingKey { + case id + case type + case title + case subtitle + case priority + case ttlSec = "ttl_sec" + } +} diff --git a/IrisCompanion/iris/Models/WinnerEnvelope.swift b/IrisCompanion/iris/Models/WinnerEnvelope.swift new file mode 100644 index 0000000..b6f5e58 --- /dev/null +++ b/IrisCompanion/iris/Models/WinnerEnvelope.swift @@ -0,0 +1,100 @@ +// +// WinnerEnvelope.swift +// iris +// +// Created by Codex. +// + +import Foundation + +struct WinnerEnvelope: Codable, Equatable { + struct DebugInfo: Codable, Equatable { + let reason: String + let source: String + } + + let schema: Int + let generatedAt: Int + let winner: Winner + let debug: DebugInfo? + + enum CodingKeys: String, CodingKey { + case schema + case generatedAt = "generated_at" + case winner + case debug + } + + static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> WinnerEnvelope { + let winner = Winner( + id: "quiet-000", + type: .allQuiet, + title: "All Quiet", + subtitle: "No urgent updates", + priority: 0.05, + ttlSec: 300 + ) + return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: .init(reason: reason, source: source)) + } +} + +enum EnvelopeValidationError: Error, LocalizedError { + case invalidSchema(Int) + case invalidPriority(Double) + case invalidTTL(Int) + + var errorDescription: String? { + switch self { + case .invalidSchema(let schema): + return "Invalid schema \(schema). Expected 1." + case .invalidPriority(let priority): + return "Invalid priority \(priority). Must be between 0 and 1." + case .invalidTTL(let ttl): + return "Invalid ttl \(ttl). Must be greater than 0." + } + } +} + +func validateEnvelope(_ envelope: WinnerEnvelope) throws -> WinnerEnvelope { + guard envelope.schema == 1 else { + throw EnvelopeValidationError.invalidSchema(envelope.schema) + } + guard envelope.winner.priority >= 0.0, envelope.winner.priority <= 1.0 else { + throw EnvelopeValidationError.invalidPriority(envelope.winner.priority) + } + guard envelope.winner.ttlSec > 0 else { + throw EnvelopeValidationError.invalidTTL(envelope.winner.ttlSec) + } + + let validatedWinner = Winner( + id: envelope.winner.id, + type: envelope.winner.type, + title: envelope.winner.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: envelope.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: envelope.winner.priority, + ttlSec: envelope.winner.ttlSec + ) + + return WinnerEnvelope( + schema: envelope.schema, + generatedAt: envelope.generatedAt, + winner: validatedWinner, + debug: envelope.debug + ) +} + +enum TextConstraints { + static let titleMax = 26 + static let subtitleMax = 30 + static let ellipsis = "..." +} + +extension String { + func truncated(maxLength: Int) -> String { + guard count > maxLength else { return self } + let ellipsisCount = TextConstraints.ellipsis.count + guard maxLength > ellipsisCount else { return String(prefix(maxLength)) } + return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis + } +} + diff --git a/IrisCompanion/iris/Network/LocalServer.swift b/IrisCompanion/iris/Network/LocalServer.swift new file mode 100644 index 0000000..ea4fcc4 --- /dev/null +++ b/IrisCompanion/iris/Network/LocalServer.swift @@ -0,0 +1,383 @@ +// +// LocalServer.swift +// iris +// +// Created by Codex. +// + +import Foundation +import Network + +final class LocalServer: ObservableObject { + @Published private(set) var isRunning = false + @Published private(set) var port: Int + @Published private(set) var sseClientCount = 0 + @Published private(set) var lastWinnerTitle = "All Quiet" + @Published private(set) var lastWinnerSubtitle = "No urgent updates" + @Published private(set) var lastBroadcastAt: Date? = nil + @Published private(set) var listenerState: String = "idle" + @Published private(set) var listenerError: String? = nil + @Published private(set) var lastConnectionAt: Date? = nil + @Published private(set) var localAddresses: [String] = [] + + private let queue = DispatchQueue(label: "iris.localserver.queue") + private var listener: NWListener? + private var browser: NWBrowser? + private var startDate = Date() + private var currentEnvelope: WinnerEnvelope + private var heartbeatTimer: DispatchSourceTimer? + private var addressTimer: DispatchSourceTimer? + private var requestBuffers: [ObjectIdentifier: Data] = [:] + private var clients: [ObjectIdentifier: SSEClient] = [:] + + init(port: Int = 8765) { + self.port = port + let winner = Winner( + id: "quiet-000", + type: .allQuiet, + title: "All Quiet", + subtitle: "No urgent updates", + priority: 0.05, + ttlSec: 300 + ) + self.currentEnvelope = WinnerEnvelope( + schema: 1, + generatedAt: Int(Date().timeIntervalSince1970), + winner: winner, + debug: nil + ) + } + + var testURL: String { + "http://172.20.10.1:\(port)/v1/stream" + } + + func start() { + guard listener == nil else { return } + let parameters = NWParameters.tcp + do { + let portValue = NWEndpoint.Port(rawValue: UInt16(port)) ?? 8765 + let listener = try NWListener(using: parameters, on: portValue) + listener.newConnectionHandler = { [weak self] connection in + self?.handleNewConnection(connection) + } + listener.stateUpdateHandler = { [weak self] state in + DispatchQueue.main.async { + self?.listenerState = "\(state)" + if case .failed(let error) = state { + self?.listenerError = "\(error)" + } + self?.isRunning = (state == .ready) + } + } + self.listener = listener + self.startDate = Date() + listener.start(queue: queue) + startHeartbeat() + startLocalNetworkPrompt() + startAddressUpdates() + } catch { + DispatchQueue.main.async { + self.isRunning = false + self.listenerState = "failed" + self.listenerError = "\(error)" + } + } + } + + func stop() { + listener?.cancel() + listener = nil + stopLocalNetworkPrompt() + stopHeartbeat() + stopAddressUpdates() + closeAllClients() + DispatchQueue.main.async { + self.isRunning = false + } + } + + func broadcastWinner(_ envelope: WinnerEnvelope) { + let validated = (try? validateEnvelope(envelope)) ?? envelope + currentEnvelope = validated + DispatchQueue.main.async { + self.lastWinnerTitle = validated.winner.title + self.lastWinnerSubtitle = validated.winner.subtitle + self.lastBroadcastAt = Date() + } + let data = sseEvent(name: "winner", payload: jsonLine(from: validated)) + broadcast(data: data) + } + + private func handleNewConnection(_ connection: NWConnection) { + DispatchQueue.main.async { + self.lastConnectionAt = Date() + } + connection.stateUpdateHandler = { [weak self] state in + if case .failed = state { + self?.removeClient(for: connection) + } else if case .cancelled = state { + self?.removeClient(for: connection) + } + } + connection.start(queue: queue) + receiveRequest(on: connection) + } + + private func receiveRequest(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in + guard let self = self else { return } + if let data = data, !data.isEmpty { + let key = ObjectIdentifier(connection) + var buffer = self.requestBuffers[key] ?? Data() + buffer.append(data) + self.requestBuffers[key] = buffer + if let requestLine = self.parseRequestLine(from: buffer) { + self.requestBuffers[key] = nil + self.handleRequest(requestLine: requestLine, connection: connection) + return + } + } + if isComplete || error != nil { + self.requestBuffers[ObjectIdentifier(connection)] = nil + self.removeClient(for: connection) + connection.cancel() + return + } + self.receiveRequest(on: connection) + } + } + + private func parseRequestLine(from data: Data) -> String? { + guard let string = String(data: data, encoding: .utf8) else { return nil } + guard let range = string.range(of: "\r\n") else { return nil } + return String(string[..= 2 else { + sendResponse(status: "400 Bad Request", body: "{}", contentType: "application/json", connection: connection) + return + } + let method = parts[0] + let path = String(parts[1]) + guard method == "GET" else { + sendResponse(status: "405 Method Not Allowed", body: "{}", contentType: "application/json", connection: connection) + return + } + + switch path { + case "/v1/health": + let uptime = Int(Date().timeIntervalSince(startDate)) + let body = "{\"ok\":true,\"uptime_sec\":\(uptime)}" + sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection) + case "/v1/winner": + let body = jsonLine(from: currentEnvelope) + sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection) + case "/v1/stream": + startSSE(connection) + default: + sendResponse(status: "404 Not Found", body: "{}", contentType: "application/json", connection: connection) + } + } + + private func sendResponse(status: String, body: String, contentType: String, connection: NWConnection) { + let response = """ + HTTP/1.1 \(status)\r\n\ + Content-Type: \(contentType)\r\n\ + Content-Length: \(body.utf8.count)\r\n\ + Connection: close\r\n\ + \r\n\ + \(body) + """ + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func startSSE(_ connection: NWConnection) { + let headers = """ + HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\n\ + Connection: keep-alive\r\n\ + \r\n + """ + connection.send(content: headers.data(using: .utf8), completion: .contentProcessed { [weak self] error in + if error != nil { + connection.cancel() + return + } + self?.addClient(connection) + let initial = self?.sseEvent(name: "feed", payload: self?.initialFeedJSON() ?? "{}") ?? Data() + connection.send(content: initial, completion: .contentProcessed { _ in }) + let status = self?.sseEvent(name: "status", payload: self?.statusJSON() ?? "{}") ?? Data() + connection.send(content: status, completion: .contentProcessed { _ in }) + }) + } + + private func addClient(_ connection: NWConnection) { + let key = ObjectIdentifier(connection) + clients[key] = SSEClient(connection: connection) + DispatchQueue.main.async { + self.sseClientCount = self.clients.count + } + } + + private func removeClient(for connection: NWConnection) { + let key = ObjectIdentifier(connection) + if clients.removeValue(forKey: key) != nil { + DispatchQueue.main.async { + self.sseClientCount = self.clients.count + } + } + } + + private func closeAllClients() { + for client in clients.values { + client.connection.cancel() + } + clients.removeAll() + DispatchQueue.main.async { + self.sseClientCount = 0 + } + } + + private func startHeartbeat() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in + guard let self = self else { return } + let data = self.sseEvent(name: "ping", payload: "{}") + self.broadcast(data: data) + } + timer.resume() + heartbeatTimer = timer + } + + private func stopHeartbeat() { + heartbeatTimer?.cancel() + heartbeatTimer = nil + } + + private func startAddressUpdates() { + updateLocalAddresses() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 10, repeating: 10) + timer.setEventHandler { [weak self] in + self?.updateLocalAddresses() + } + timer.resume() + addressTimer = timer + } + + private func stopAddressUpdates() { + addressTimer?.cancel() + addressTimer = nil + } + + private func updateLocalAddresses() { + let addresses = Self.localInterfaceAddresses() + DispatchQueue.main.async { + self.localAddresses = addresses + } + } + + private func startLocalNetworkPrompt() { + guard browser == nil else { return } + let parameters = NWParameters.tcp + let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters) + browser.stateUpdateHandler = { _ in } + browser.browseResultsChangedHandler = { _, _ in } + self.browser = browser + browser.start(queue: queue) + } + + private func stopLocalNetworkPrompt() { + browser?.cancel() + browser = nil + } + + private func broadcast(data: Data) { + for (key, client) in clients { + client.connection.send(content: data, completion: .contentProcessed { [weak self] error in + if error != nil { + self?.clients.removeValue(forKey: key) + DispatchQueue.main.async { + self?.sseClientCount = self?.clients.count ?? 0 + } + } + }) + } + } + + private func jsonLine(from envelope: WinnerEnvelope) -> String { + let encoder = JSONEncoder() + if let data = try? encoder.encode(envelope), + let string = String(data: data, encoding: .utf8) { + return string + } + return "{}" + } + + private func initialFeedJSON() -> String { + return "{\"schema\":1,\"generated_at\":1767716400,\"feed\":[{\"id\":\"demo:welcome\",\"type\":\"INFO\",\"title\":\"Glass Now online\",\"subtitle\":\"Connected to iPhone\",\"priority\":0.8,\"ttl_sec\":86400,\"bucket\":\"RIGHT_NOW\",\"actions\":[\"DISMISS\"]},{\"id\":\"demo:next\",\"type\":\"INFO\",\"title\":\"Next: Calendar\",\"subtitle\":\"Then Weather + POI\",\"priority\":0.4,\"ttl_sec\":86400,\"bucket\":\"FYI\",\"actions\":[\"DISMISS\"]}],\"meta\":{\"winner_id\":\"demo:welcome\",\"unread_count\":2}}" + } + + private func statusJSON() -> String { + let uptime = Int(Date().timeIntervalSince(startDate)) + return "{\"server\":\"iphone\",\"version\":\"v1\",\"uptime_sec\":\(uptime)}" + } + + private func sseEvent(name: String, payload: String?) -> Data { + let dataLine = payload ?? "{}" + let message = "event: \(name)\n" + "data: \(dataLine)\n\n" + return Data(message.utf8) + } +} + +private struct SSEClient { + let connection: NWConnection +} + +private extension LocalServer { + static func localInterfaceAddresses() -> [String] { + var results: [String] = [] + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let firstAddr = addrList else { + return results + } + defer { freeifaddrs(addrList) } + + var ptr: UnsafeMutablePointer? = firstAddr + while let addr = ptr?.pointee { + let flags = Int32(addr.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + guard isUp, !isLoopback, let sa = addr.ifa_addr else { + ptr = addr.ifa_next + continue + } + let family = sa.pointee.sa_family + if family == UInt8(AF_INET) { + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + sa, + socklen_t(sa.pointee.sa_len), + &hostname, + socklen_t(hostname.count), + nil, + 0, + NI_NUMERICHOST + ) + if result == 0, let ip = String(validatingUTF8: hostname) { + let name = String(cString: addr.ifa_name) + results.append("\(name): \(ip)") + } + } + ptr = addr.ifa_next + } + return results.sorted() + } +} diff --git a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift new file mode 100644 index 0000000..9e15414 --- /dev/null +++ b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift @@ -0,0 +1,563 @@ +// +// ContextOrchestrator.swift +// iris +// +// Created by Codex. +// + +import CoreLocation +import Foundation +import MediaPlayer +import MusicKit +import os + +@available(iOS 16.0, *) +@MainActor +final class ContextOrchestrator: NSObject, ObservableObject { + @Published private(set) var authorization: CLAuthorizationStatus = .notDetermined + @Published private(set) var lastLocation: CLLocation? = nil + @Published private(set) var lastRecomputeAt: Date? = nil + @Published private(set) var lastRecomputeReason: String? = nil + @Published private(set) var lastWinner: WinnerEnvelope? = nil + @Published private(set) var lastError: String? = nil + @Published private(set) var lastCandidates: [Candidate] = [] + @Published private(set) var lastWeatherDiagnostics: [String: String] = [:] + @Published private(set) var lastPipelineElapsedMs: Int? = nil + @Published private(set) var lastFetchFailed: Bool = false + @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined + @Published private(set) var nowPlaying: NowPlayingSnapshot? = nil + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator") + + private let locationManager = CLLocationManager() + private let weatherDataSource = WeatherDataSource() + private let calendarDataSource = CalendarDataSource() + private let poiDataSource = POIDataSource() + private let ranker: HeuristicRanker + private let store: FeedStore + private let server: LocalServer + private let ble: BlePeripheralManager + private let nowPlayingMonitor = NowPlayingMonitor() + + private var lastRecomputeLocation: CLLocation? = nil + private var lastRecomputeAccuracy: CLLocationAccuracy? = nil + private var recomputeInFlight = false + private var lastRecomputeAttemptAt: Date? = nil + + init(store: FeedStore = FeedStore(), + server: LocalServer = LocalServer(), + ble: BlePeripheralManager) { + self.store = store + self.server = server + self.ble = ble + self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(candidateId: id) }) + super.init() + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.distanceFilter = kCLDistanceFilterNone + locationManager.pausesLocationUpdatesAutomatically = false + locationManager.allowsBackgroundLocationUpdates = true + + ble.onFirstSubscribe = { [weak self] in + Task { @MainActor in + self?.logger.info("BLE subscribed: pushing latest winner") + self?.pushLatestWinnerToBle() + } + } + ble.onControlCommand = { [weak self] command in + Task { @MainActor in + self?.handleBleControl(command) + } + } + + nowPlayingMonitor.onUpdate = { [weak self] update in + Task { @MainActor in + guard let self else { return } + self.musicAuthorization = update.authorization + self.nowPlaying = update.snapshot + self.pushLatestWinnerToBle() + } + } + + let feed = store.getFeed() + lastWinner = feed.asWinnerEnvelope() + } + + func start() { + authorization = locationManager.authorizationStatus + logger.info("start auth=\(String(describing: self.authorization), privacy: .public)") + server.start() + nowPlayingMonitor.start() + requestPermissionsIfNeeded() + locationManager.startUpdatingLocation() + } + + func stop() { + locationManager.stopUpdatingLocation() + nowPlayingMonitor.stop() + } + + func recomputeNow(reason: String = "manual") { + guard let location = lastLocation ?? locationManager.location else { + logger.info("recomputeNow skipped: no location") + return + } + maybeRecompute(for: location, reason: reason, force: true) + } + + func sendFixtureFeedNow() { + guard let url = Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json", subdirectory: "ProtocolFixtures") + ?? Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json"), + let data = try? Data(contentsOf: url) else { + logger.error("fixture feed missing in bundle") + return + } + ble.sendOpaque(data.trimmedTrailingWhitespace(), msgType: 1) + logger.info("sent fixture feed bytes=\(data.count)") + } + + private func requestPermissionsIfNeeded() { + switch locationManager.authorizationStatus { + case .notDetermined: + logger.info("requestWhenInUseAuthorization") + locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse: + logger.info("requestAlwaysAuthorization") + locationManager.requestAlwaysAuthorization() + case .authorizedAlways: + break + case .restricted, .denied: + lastError = "Location permission denied." + @unknown default: + break + } + } + + private func maybeRecompute(for location: CLLocation, reason: String, force: Bool) { + let now = Date() + let hardThrottleSec: TimeInterval = 60 + if !force, let lastAttempt = lastRecomputeAttemptAt, now.timeIntervalSince(lastAttempt) < hardThrottleSec { + logger.info("skip recompute (throttle) reason=\(reason, privacy: .public)") + return + } + + lastRecomputeAttemptAt = now + if recomputeInFlight { + logger.info("skip recompute (in-flight) reason=\(reason, privacy: .public)") + return + } + recomputeInFlight = true + lastRecomputeReason = reason + + logger.info("recompute start reason=\(reason, privacy: .public) lat=\(location.coordinate.latitude, format: .fixed(precision: 5)) lon=\(location.coordinate.longitude, format: .fixed(precision: 5)) acc=\(location.horizontalAccuracy, format: .fixed(precision: 1))") + + Task { + await self.recomputePipeline(location: location, reason: reason) + } + } + + private func shouldTriggerRecompute(for location: CLLocation) -> (Bool, String) { + if lastRecomputeAt == nil { + return (true, "initial") + } + let now = Date() + if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 { + return (true, "timer_15m") + } + if let lastLoc = lastRecomputeLocation { + let dist = location.distance(from: lastLoc) + if dist > 250 { + return (true, "moved_250m") + } + } + if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 { + if lastAcc - location.horizontalAccuracy > 50 { + return (true, "accuracy_improved_50m") + } + } + return (false, "no_trigger") + } + + private func recomputePipeline(location: CLLocation, reason: String) async { + defer { + Task { @MainActor in + self.recomputeInFlight = false + } + } + + let nowEpoch = Int(Date().timeIntervalSince1970) + let userContext = UserContext(isMoving: location.speed >= 1.0, city: "London") + + let start = Date() + async let weatherResult = withTimeoutResult(seconds: 6) { + await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch) + } + async let calendarResult = withTimeoutResult(seconds: 6) { + await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch) + } + async let poiResult = withTimeoutResult(seconds: 6) { + try await self.poiDataSource.candidates(for: location, now: nowEpoch) + } + + let wxRes = await weatherResult + let calRes = await calendarResult + let poiRes = await poiResult + + var candidates: [Candidate] = [] + var fetchFailed = false + var wxDiagnostics: [String: String] = [:] + var weatherNowCandidate: Candidate? = nil + + switch wxRes { + case .success(let wx): + candidates.append(contentsOf: wx.candidates) + wxDiagnostics = wx.diagnostics + weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") }) + if let wxErr = wx.weatherKitError { + fetchFailed = true + logger.warning("weather fetch error: \(wxErr, privacy: .public)") + } + case .failure(let error): + fetchFailed = true + logger.error("weather fetch failed: \(String(describing: error), privacy: .public)") + } + + switch poiRes { + case .success(let pois): + candidates.append(contentsOf: pois) + case .failure(let error): + fetchFailed = true + logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") + } + + switch calRes { + case .success(let cal): + candidates.append(contentsOf: cal.candidates) + if let err = cal.error { + fetchFailed = true + logger.warning("calendar error: \(err, privacy: .public)") + } + case .failure(let error): + fetchFailed = true + logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)") + } + + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + lastPipelineElapsedMs = elapsedMs + lastFetchFailed = fetchFailed + lastCandidates = candidates + lastWeatherDiagnostics = wxDiagnostics + + logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") + + if fetchFailed, candidates.isEmpty { + let fallbackFeed = store.getFeed(now: nowEpoch) + let fallbackWinner = fallbackFeed.asWinnerEnvelope() + lastWinner = fallbackWinner + lastError = "Fetch failed; using previous winner." + server.broadcastWinner(fallbackWinner) + ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1) + return + } + + let unsuppressed = candidates + .filter { $0.type != .currentWeather } + .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } + let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext) + let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil) + let validated = (try? validateEnvelope(envelope)) ?? envelope + + let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate) + store.setFeed(feedEnvelope, now: nowEpoch) + lastWinner = validated + lastRecomputeAt = Date() + lastRecomputeLocation = location + lastRecomputeAccuracy = location.horizontalAccuracy + lastError = fetchFailed ? "Partial fetch failure." : nil + + logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)") + + server.broadcastWinner(validated) + ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1) + } + + private func pushLatestWinnerToBle() { + let nowEpoch = Int(Date().timeIntervalSince1970) + let feed = store.getFeed(now: nowEpoch) + ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1) + } + + private func handleBleControl(_ command: String) { + guard !command.isEmpty else { return } + if command == "REQ_FULL" { + logger.info("BLE control REQ_FULL") + pushLatestWinnerToBle() + return + } + if command.hasPrefix("ACK:") { + logger.info("BLE control \(command, privacy: .public)") + return + } + logger.info("BLE control unknown=\(command, privacy: .public)") + } + + private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { + guard let nowPlayingCard = nowPlaying?.asFeedCard(baseGeneratedAt: base.generatedAt, now: now) else { + return base + } + + var cards = base.feed.filter { $0.type != .nowPlaying } + + // Append after existing FYI cards (e.g. weather). + cards.append(nowPlayingCard) + + return FeedEnvelope( + schema: base.schema, + generatedAt: base.generatedAt, + feed: cards, + meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count) + ) + } +} + +extension ContextOrchestrator: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + authorization = manager.authorizationStatus + logger.info("auth changed=\(String(describing: self.authorization), privacy: .public)") + requestPermissionsIfNeeded() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + logger.error("location error: \(String(describing: error), privacy: .public)") + lastError = "Location error: \(error)" + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let best = locations.sorted(by: { $0.horizontalAccuracy < $1.horizontalAccuracy }).first else { return } + lastLocation = best + + let (should, reason) = shouldTriggerRecompute(for: best) + if should { + maybeRecompute(for: best, reason: reason, force: false) + } + } +} + +enum TimeoutError: Error { + case timedOut +} + +func withTimeoutResult(seconds: Double, operation: @escaping () async throws -> T) async -> Result { + do { + let value = try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError.timedOut + } + let result = try await group.next()! + group.cancelAll() + return result + } + return .success(value) + } catch { + return .failure(error) + } +} + +@available(iOS 16.0, *) +struct NowPlayingSnapshot: Equatable, Sendable { + let itemId: String + let title: String + let artist: String? + let album: String? + let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus + + func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard { + let desiredLifetimeSec = 30 + let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec) + + let subtitleParts = [artist, album] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: " • ") + + return FeedCard( + id: "music:now:\(itemId)", + type: .nowPlaying, + title: title.truncated(maxLength: TextConstraints.titleMax), + subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: playbackStatus == .playing ? 0.35 : 0.2, + ttlSec: ttl, + condition: nil, + bucket: .fyi, + actions: ["DISMISS"] + ) + } +} + +@available(iOS 16.0, *) +@MainActor +final class NowPlayingMonitor { + struct Update: Sendable { + let authorization: MusicAuthorization.Status + let snapshot: NowPlayingSnapshot? + } + + var onUpdate: ((Update) -> Void)? = nil + + private let player = SystemMusicPlayer.shared + private let mpController = MPMusicPlayerController.systemMusicPlayer + private var observers: [NSObjectProtocol] = [] + private var pollTimer: DispatchSourceTimer? + private var isRunning = false + + private var authorization: MusicAuthorization.Status = .notDetermined + private var lastSnapshot: NowPlayingSnapshot? = nil + + func start() { + guard !isRunning else { return } + isRunning = true + + mpController.beginGeneratingPlaybackNotifications() + observers.append( + NotificationCenter.default.addObserver( + forName: .MPMusicPlayerControllerNowPlayingItemDidChange, + object: mpController, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.refresh(reason: "mp_now_playing_changed") } + } + ) + observers.append( + NotificationCenter.default.addObserver( + forName: .MPMusicPlayerControllerPlaybackStateDidChange, + object: mpController, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.refresh(reason: "mp_playback_state_changed") } + } + ) + + startPolling() + + Task { @MainActor in + await ensureAuthorization() + refresh(reason: "start") + } + } + + func stop() { + guard isRunning else { return } + isRunning = false + + pollTimer?.cancel() + pollTimer = nil + + for token in observers { + NotificationCenter.default.removeObserver(token) + } + observers.removeAll() + + mpController.endGeneratingPlaybackNotifications() + } + + private func startPolling() { + guard pollTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 2, repeating: 2) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.refresh(reason: "poll") + } + timer.resume() + pollTimer = timer + } + + private func ensureAuthorization() async { + if authorization == .notDetermined { + authorization = await MusicAuthorization.request() + onUpdate?(Update(authorization: authorization, snapshot: lastSnapshot)) + } + } + + private func refresh(reason: String) { + guard authorization == .authorized else { + if lastSnapshot != nil { + lastSnapshot = nil + onUpdate?(Update(authorization: authorization, snapshot: nil)) + } + return + } + + let playback = player.state.playbackStatus + guard playback != .stopped else { + if lastSnapshot != nil { + lastSnapshot = nil + onUpdate?(Update(authorization: authorization, snapshot: nil)) + } + return + } + + let mpItem = mpController.nowPlayingItem + let musicKitItem = player.queue.currentEntry?.item + + let itemId = sanitizeId( + musicKitItem.map { String(describing: $0.id) } + ?? mpItem.map { "mp:\($0.persistentID)" } + ?? UUID().uuidString + ) + + let musicKitTitle = musicKitItem.map(nowPlayingTitle(from:)) + let title = normalizeTitle(musicKitTitle) ?? normalizeTitle(mpItem?.title) ?? "Now Playing" + + let artist = normalizePart(musicKitItem.flatMap(nowPlayingArtist(from:))) ?? normalizePart(mpItem?.artist) + let album = normalizePart(musicKitItem.flatMap(nowPlayingAlbum(from:))) ?? normalizePart(mpItem?.albumTitle) + + let snapshot = NowPlayingSnapshot(itemId: itemId, title: title, artist: artist, album: album, playbackStatus: playback) + + guard snapshot != lastSnapshot else { return } + lastSnapshot = snapshot + onUpdate?(Update(authorization: authorization, snapshot: snapshot)) + } + + private func nowPlayingTitle(from item: MusicItem) -> String { + if let song = item as? Song { return song.title } + if let album = item as? Album { return album.title } + if let playlist = item as? Playlist { return playlist.name } + return "Now Playing" + } + + private func nowPlayingArtist(from item: MusicItem) -> String? { + if let song = item as? Song { return song.artistName } + if let album = item as? Album { return album.artistName } + return nil + } + + private func nowPlayingAlbum(from item: MusicItem) -> String? { + if let song = item as? Song { return song.albumTitle } + return nil + } + + private func sanitizeId(_ raw: String) -> String { + raw + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "_") + .replacingOccurrences(of: "\t", with: "_") + } + + private func normalizePart(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func normalizeTitle(_ raw: String?) -> String? { + guard let value = normalizePart(raw) else { return nil } + if value == "Now Playing" { return nil } + return value + } +} diff --git a/IrisCompanion/iris/ProtocolFixtures/all_quiet_example.json b/IrisCompanion/iris/ProtocolFixtures/all_quiet_example.json new file mode 100644 index 0000000..a7902f8 --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/all_quiet_example.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "generated_at": 1736217620, + "winner": { + "id": "quiet-000", + "type": "ALL_QUIET", + "title": "All Quiet", + "subtitle": "No urgent updates", + "priority": 0.05, + "ttl_sec": 300 + }, + "debug": { + "reason": "no_candidates", + "source": "engine" + } +} diff --git a/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json b/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json new file mode 100644 index 0000000..33de771 --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json @@ -0,0 +1 @@ +{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":3}} diff --git a/IrisCompanion/iris/ProtocolFixtures/poi_nearby_example.json b/IrisCompanion/iris/ProtocolFixtures/poi_nearby_example.json new file mode 100644 index 0000000..19ea8d3 --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/poi_nearby_example.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "generated_at": 1736217615, + "winner": { + "id": "poi-park-003", + "type": "POI_NEARBY", + "title": "Riverside Park", + "subtitle": "2 min walk, open now", + "priority": 0.45, + "ttl_sec": 1200 + }, + "debug": { + "reason": "nearby_poi", + "source": "local_search" + } +} diff --git a/IrisCompanion/iris/ProtocolFixtures/transit_example.json b/IrisCompanion/iris/ProtocolFixtures/transit_example.json new file mode 100644 index 0000000..1768469 --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/transit_example.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "generated_at": 1736217610, + "winner": { + "id": "transit-005", + "type": "TRANSIT", + "title": "Train 4 Arrival", + "subtitle": "Platform 2 in 6 min", + "priority": 0.7, + "ttl_sec": 600 + }, + "debug": { + "reason": "upcoming_departure", + "source": "gtfs" + } +} diff --git a/IrisCompanion/iris/ProtocolFixtures/weather_alert_rain_soon.json b/IrisCompanion/iris/ProtocolFixtures/weather_alert_rain_soon.json new file mode 100644 index 0000000..bfceb6d --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/weather_alert_rain_soon.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "generated_at": 1736217600, + "winner": { + "id": "alert-rain-001", + "type": "WEATHER_ALERT", + "title": "Rain Soon", + "subtitle": "Light rain in 20 min", + "priority": 0.92, + "ttl_sec": 900 + }, + "debug": { + "reason": "incoming_precip", + "source": "weatherkit" + } +} diff --git a/IrisCompanion/iris/ProtocolFixtures/weather_warning_example.json b/IrisCompanion/iris/ProtocolFixtures/weather_warning_example.json new file mode 100644 index 0000000..8dbd701 --- /dev/null +++ b/IrisCompanion/iris/ProtocolFixtures/weather_warning_example.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "generated_at": 1736217605, + "winner": { + "id": "warn-wind-002", + "type": "WEATHER_WARNING", + "title": "High Wind", + "subtitle": "Gusts up to 35 mph", + "priority": 0.78, + "ttl_sec": 1800 + }, + "debug": { + "reason": "wind_advisory", + "source": "noaa" + } +} diff --git a/IrisCompanion/iris/Utils/DataTrimming.swift b/IrisCompanion/iris/Utils/DataTrimming.swift new file mode 100644 index 0000000..6d7dd3f --- /dev/null +++ b/IrisCompanion/iris/Utils/DataTrimming.swift @@ -0,0 +1,25 @@ +// +// DataTrimming.swift +// iris +// +// Created by Codex. +// + +import Foundation + +extension Data { + func trimmedTrailingWhitespace() -> Data { + guard !isEmpty else { return self } + var endIndex = count + while endIndex > 0 { + let b = self[endIndex - 1] + if b == 0x0A || b == 0x0D || b == 0x20 || b == 0x09 { + endIndex -= 1 + } else { + break + } + } + return prefix(endIndex) + } +} + diff --git a/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift b/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift new file mode 100644 index 0000000..5336fac --- /dev/null +++ b/IrisCompanion/iris/ViewModels/CandidatesViewModel.swift @@ -0,0 +1,71 @@ +// +// CandidatesViewModel.swift +// iris +// +// Created by Codex. +// + +import CoreLocation +import Foundation +import os + +@MainActor +final class CandidatesViewModel: ObservableObject { + @Published private(set) var candidates: [Candidate] = [] + @Published private(set) var lastUpdatedAt: Date? = nil + @Published private(set) var isLoading = false + @Published private(set) var lastError: String? = nil + @Published private(set) var diagnostics: [String: String] = [:] + + var demoLatitude: Double = 51.5074 + var demoLongitude: Double = -0.1278 + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "CandidatesViewModel") + + func refresh() { + guard !isLoading else { return } + isLoading = true + lastError = nil + diagnostics = [:] + + let location = CLLocation(latitude: demoLatitude, longitude: demoLongitude) + let now = Int(Date().timeIntervalSince1970) + logger.info("Refresh start lat=\(self.demoLatitude, format: .fixed(precision: 4)) lon=\(self.demoLongitude, format: .fixed(precision: 4)) now=\(now)") + + Task { + defer { + Task { @MainActor in + self.isLoading = false + self.lastUpdatedAt = Date() + } + } + + if #available(iOS 16.0, *) { + let ds = WeatherDataSource() + let result = await ds.candidatesWithDiagnostics(for: location, now: now) + await MainActor.run { + self.candidates = result.candidates.sorted { $0.confidence > $1.confidence } + self.diagnostics = result.diagnostics + if let error = result.weatherKitError { + self.lastError = "WeatherKit error: \(error)" + } + } + + if let error = result.weatherKitError { + self.logger.error("WeatherKit error: \(error)") + } + self.logger.info("Produced candidates count=\(result.candidates.count)") + for c in result.candidates { + self.logger.info("Candidate id=\(c.id, privacy: .public) type=\(c.type.rawValue, privacy: .public) conf=\(c.confidence, format: .fixed(precision: 2)) ttl=\(c.ttlSec) title=\(c.title, privacy: .public)") + } + if result.candidates.isEmpty { + self.logger.info("Diagnostics: \(String(describing: result.diagnostics), privacy: .public)") + } + } else { + await MainActor.run { + self.candidates = [] + self.lastError = "WeatherKit requires iOS 16+." + } + } + } + } +} diff --git a/IrisCompanion/iris/Views/BleStatusView.swift b/IrisCompanion/iris/Views/BleStatusView.swift new file mode 100644 index 0000000..e909ddf --- /dev/null +++ b/IrisCompanion/iris/Views/BleStatusView.swift @@ -0,0 +1,124 @@ +// +// BleStatusView.swift +// iris +// +// Created by Codex. +// + +import SwiftUI + +struct BleStatusView: View { + @EnvironmentObject private var ble: BlePeripheralManager + @EnvironmentObject private var orchestrator: ContextOrchestrator + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("GlassNow BLE") + .font(.title2.bold()) + Text("Bluetooth: \(bluetoothStateText)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Toggle(isOn: $ble.advertisingEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Advertising") + .font(.headline) + Text(ble.isAdvertising ? "On" : "Off") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .onChange(of: ble.advertisingEnabled) { _ in + ble.start() + } + + VStack(alignment: .leading, spacing: 8) { + Text("Connection") + .font(.headline) + Text("Subscribed: \(ble.isSubscribed ? "Yes" : "No")") + .font(.subheadline) + Text("Subscribers: \(ble.subscribedCount)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Telemetry") + .font(.headline) + Text("Last msgId: \(ble.lastMsgIdSent)") + .font(.subheadline) + Text("Last ping: \(ble.lastPingAt.map { timeOnly(from: $0) } ?? "Never")") + .font(.subheadline) + Text("Last data: \(ble.lastDataAt.map { timeOnly(from: $0) } ?? "Never")") + .font(.subheadline) + Text("Notify queue: \(ble.notifyQueueDepth)") + .font(.subheadline) + .foregroundStyle(.secondary) + if ble.droppedNotifyPackets > 0 { + Text("Dropped notify packets: \(ble.droppedNotifyPackets)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text("Last notify: \(ble.lastNotifyAt.map { timeOnly(from: $0) } ?? "Never")") + .font(.subheadline) + .foregroundStyle(.secondary) + if let cmd = ble.lastCommand, !cmd.isEmpty { + Text("Last control: \(cmd)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 12) { + Button("Send Fixture Feed Now") { + orchestrator.sendFixtureFeedNow() + } + .buttonStyle(.borderedProminent) + + Button("Copy UUIDs") { + ble.copyUUIDsToPasteboard() + } + .buttonStyle(.bordered) + } + + VStack(alignment: .leading, spacing: 8) { + Text("UUIDs") + .font(.headline) + Text("Service: \(BlePeripheralManager.serviceUUID.uuidString)\nFEED_TX: \(BlePeripheralManager.feedTxUUID.uuidString)\nCONTROL_RX: \(BlePeripheralManager.controlRxUUID.uuidString)") + .font(.caption) + .textSelection(.enabled) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { ble.start() } + } + + private var bluetoothStateText: String { + switch ble.bluetoothState { + case .unknown: return "Unknown" + case .resetting: return "Resetting" + case .unsupported: return "Unsupported" + case .unauthorized: return "Unauthorized" + case .poweredOff: return "Powered Off" + case .poweredOn: return "Powered On" + @unknown default: return "Other" + } + } + + private func timeOnly(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +struct BleStatusView_Previews: PreviewProvider { + static var previews: some View { + Text("Preview unavailable (requires EnvironmentObjects).") + } +} diff --git a/IrisCompanion/iris/Views/CandidatesView.swift b/IrisCompanion/iris/Views/CandidatesView.swift new file mode 100644 index 0000000..b4944b8 --- /dev/null +++ b/IrisCompanion/iris/Views/CandidatesView.swift @@ -0,0 +1,120 @@ +// +// CandidatesView.swift +// iris +// +// Created by Codex. +// + +import SwiftUI + +struct CandidatesView: View { + @StateObject private var model = CandidatesViewModel() + + var body: some View { + NavigationStack { + List { + Section("Source") { + LabeledContent("Location") { + Text("London (demo)") + } + if let updated = model.lastUpdatedAt { + LabeledContent("Last update") { Text(timeOnly(from: updated)) } + } else { + LabeledContent("Last update") { Text("Never") } + } + if let error = model.lastError { + Text(error) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if !model.diagnostics.isEmpty { + Section("Diagnostics") { + ForEach(model.diagnostics.keys.sorted(), id: \.self) { key in + LabeledContent(key) { + Text(model.diagnostics[key] ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } + + Section("Candidates (\(model.candidates.count))") { + if model.candidates.isEmpty { + Text(model.isLoading ? "Loading…" : "No candidates") + .foregroundStyle(.secondary) + } else { + ForEach(model.candidates, id: \.id) { candidate in + CandidateRow(candidate: candidate) + } + } + } + } + .navigationTitle("Candidates") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(model.isLoading ? "Refreshing…" : "Refresh") { + model.refresh() + } + .disabled(model.isLoading) + } + } + .onAppear { model.refresh() } + } + } + + private func timeOnly(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +private struct CandidateRow: View { + let candidate: Candidate + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(candidate.title) + .font(.headline) + .lineLimit(1) + Spacer() + Text(candidate.type.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(candidate.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + HStack(spacing: 12) { + Text(String(format: "conf %.2f", candidate.confidence)) + Text("ttl \(candidate.ttlSec)s") + Text(expiresText(now: Int(Date().timeIntervalSince1970))) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + private func expiresText(now: Int) -> String { + let expiresAt = candidate.createdAt + candidate.ttlSec + let remaining = expiresAt - now + if remaining <= 0 { return "expired" } + if remaining < 60 { return "in \(remaining)s" } + return "in \(remaining / 60)m" + } +} + +struct CandidatesView_Previews: PreviewProvider { + static var previews: some View { + CandidatesView() + } +} diff --git a/IrisCompanion/iris/Views/OrchestratorView.swift b/IrisCompanion/iris/Views/OrchestratorView.swift new file mode 100644 index 0000000..7ab0a43 --- /dev/null +++ b/IrisCompanion/iris/Views/OrchestratorView.swift @@ -0,0 +1,176 @@ +// +// OrchestratorView.swift +// iris +// +// Created by Codex. +// + +import CoreLocation +import Foundation +import MusicKit +import SwiftUI + +@available(iOS 16.0, *) +struct OrchestratorView: View { + @EnvironmentObject private var orchestrator: ContextOrchestrator + + var body: some View { + NavigationStack { + List { + Section("Location") { + LabeledContent("Auth") { Text(authText(orchestrator.authorization)) } + if let loc = orchestrator.lastLocation { + LabeledContent("Lat/Lon") { + Text("\(format(loc.coordinate.latitude, 5)), \(format(loc.coordinate.longitude, 5))") + .textSelection(.enabled) + } + LabeledContent("Accuracy") { Text("\(Int(loc.horizontalAccuracy)) m") } + LabeledContent("Speed") { Text(speedText(loc.speed)) } + } else { + Text("No location yet") + .foregroundStyle(.secondary) + } + } + + Section("Recompute") { + LabeledContent("Last reason") { Text(orchestrator.lastRecomputeReason ?? "—") } + LabeledContent("Last time") { Text(orchestrator.lastRecomputeAt.map(timeOnly) ?? "—") } + LabeledContent("Elapsed") { Text(orchestrator.lastPipelineElapsedMs.map { "\($0) ms" } ?? "—") } + LabeledContent("Fetch failed") { Text(orchestrator.lastFetchFailed ? "Yes" : "No") } + if let err = orchestrator.lastError { + Text(err) + .font(.footnote) + .foregroundStyle(.secondary) + } + Button("Recompute Now") { orchestrator.recomputeNow() } + } + + Section("Winner") { + if let env = orchestrator.lastWinner { + Text(env.winner.title) + .font(.headline) + Text(env.winner.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("No winner yet") + .foregroundStyle(.secondary) + } + } + + Section("Now Playing") { + LabeledContent("Music auth") { Text(musicAuthText(orchestrator.musicAuthorization)) } + if let snapshot = orchestrator.nowPlaying { + Text(snapshot.title) + .font(.headline) + .lineLimit(1) + Text(nowPlayingSubtitle(snapshot)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(String(describing: snapshot.playbackStatus)) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(orchestrator.musicAuthorization == .authorized ? "Nothing playing" : "Not authorized") + .foregroundStyle(.secondary) + } + } + + Section("Candidates (\(orchestrator.lastCandidates.count))") { + if orchestrator.lastCandidates.isEmpty { + Text("No candidates") + .foregroundStyle(.secondary) + } else { + ForEach(orchestrator.lastCandidates, id: \.id) { c in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(c.title) + .font(.headline) + .lineLimit(1) + Spacer() + Text(c.type.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(c.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("conf \(String(format: "%.2f", c.confidence)) • ttl \(c.ttlSec)s") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + + if !orchestrator.lastWeatherDiagnostics.isEmpty { + Section("Weather Diagnostics") { + ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in + LabeledContent(key) { + Text(orchestrator.lastWeatherDiagnostics[key] ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } + + Section("Test") { + Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() } + } + } + .navigationTitle("Orchestrator") + } + } + + private func authText(_ s: CLAuthorizationStatus) -> String { + switch s { + case .notDetermined: return "Not Determined" + case .restricted: return "Restricted" + case .denied: return "Denied" + case .authorizedAlways: return "Always" + case .authorizedWhenInUse: return "When In Use" + @unknown default: return "Other" + } + } + + private func timeOnly(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func speedText(_ speed: CLLocationSpeed) -> String { + guard speed >= 0 else { return "—" } + return "\(String(format: "%.1f", speed)) m/s" + } + + private func musicAuthText(_ status: MusicAuthorization.Status) -> String { + switch status { + case .notDetermined: return "Not Determined" + case .denied: return "Denied" + case .restricted: return "Restricted" + case .authorized: return "Authorized" + @unknown default: return "Other" + } + } + + private func nowPlayingSubtitle(_ snapshot: NowPlayingSnapshot) -> String { + let parts = [snapshot.artist, snapshot.album] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return parts.isEmpty ? "Apple Music" : parts.joined(separator: " • ") + } + + private func format(_ value: Double, _ precision: Int) -> String { + String(format: "%.\(precision)f", value) + } +} diff --git a/IrisCompanion/iris/iris.entitlements b/IrisCompanion/iris/iris.entitlements new file mode 100644 index 0000000..ec84c03 --- /dev/null +++ b/IrisCompanion/iris/iris.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.weatherkit + + + diff --git a/IrisCompanion/iris/irisApp.swift b/IrisCompanion/iris/irisApp.swift new file mode 100644 index 0000000..d9719e7 --- /dev/null +++ b/IrisCompanion/iris/irisApp.swift @@ -0,0 +1,28 @@ +// +// irisApp.swift +// iris +// +// Created by Kenneth on 06/01/2026. +// + +import SwiftUI + +@main +struct irisApp: App { + @StateObject private var ble: BlePeripheralManager + @StateObject private var orchestrator: ContextOrchestrator + + init() { + let bleManager = BlePeripheralManager() + _ble = StateObject(wrappedValue: bleManager) + _orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager)) + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(ble) + .environmentObject(orchestrator) + } + } +} diff --git a/IrisCompanion/irisTests/irisTests.swift b/IrisCompanion/irisTests/irisTests.swift new file mode 100644 index 0000000..d3c1e64 --- /dev/null +++ b/IrisCompanion/irisTests/irisTests.swift @@ -0,0 +1,17 @@ +// +// irisTests.swift +// irisTests +// +// Created by Kenneth on 06/01/2026. +// + +import Testing +@testable import iris + +struct irisTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/IrisCompanion/irisUITests/irisUITests.swift b/IrisCompanion/irisUITests/irisUITests.swift new file mode 100644 index 0000000..1908157 --- /dev/null +++ b/IrisCompanion/irisUITests/irisUITests.swift @@ -0,0 +1,41 @@ +// +// irisUITests.swift +// irisUITests +// +// Created by Kenneth on 06/01/2026. +// + +import XCTest + +final class irisUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/IrisCompanion/irisUITests/irisUITestsLaunchTests.swift b/IrisCompanion/irisUITests/irisUITestsLaunchTests.swift new file mode 100644 index 0000000..3e873c4 --- /dev/null +++ b/IrisCompanion/irisUITests/irisUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// irisUITestsLaunchTests.swift +// irisUITests +// +// Created by Kenneth on 06/01/2026. +// + +import XCTest + +final class irisUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/IrisGlass/README.md b/IrisGlass/README.md new file mode 100644 index 0000000..9a0cdb3 --- /dev/null +++ b/IrisGlass/README.md @@ -0,0 +1,70 @@ +# IrisGlass (Glass XE24 BLE Central) + +Android app for Google Glass Explorer Edition (XE24, Android 4.4.2 / API 19) that connects as a BLE Central (GATT client) to an iPhone Peripheral and renders a “Google Now style feed” winner on a pinned LiveCard. + +## What it does +- Scans using legacy BLE APIs (`BluetoothAdapter.startLeScan`) with a 10s timeout. +- Connects + discovers the custom service: + - `A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D` +- Subscribes to notifications on `FEED_TX` by writing the CCCD (0x2902). +- Reassembles chunked notifications into a UTF-8 JSON `FeedEnvelope` and logs it. +- Parses JSON with `org.json` into a minimal model: winner title/subtitle + “+N”. +- Shows status (“Scanning…/Connecting…/Connected” or last error) on a persistent LiveCard pinned near the clock. +- Tapping the LiveCard opens a swipeable feed view (CardScrollView) with per-card actions (dismiss/snooze/save). + +Logcat tags: +- `BLE` scan/connect/subscribe +- `FEED` reassembly + parsing +- `HUD` LiveCard lifecycle + rendering + +## Using the feed UI +- Tap the pinned LiveCard to open `FeedActivity`. +- Swipe left/right to move through cards. +- Tap a card to open its actions (Glass-style overlay); `DISMISS` / `SNOOZE_*` are stored locally and stay hidden across restarts. + +## FYI static cards (Weather) +If the feed includes a card with `bucket="FYI"` and a `type` that equals `WEATHER_INFO` or contains `WEATHER` (e.g. `CURRENT_WEATHER`), the app publishes it as a Glass “static card” via an Android notification using a custom `RemoteViews` layout modeled after the “Google Now Weather” card style (see `app/src/main/res/layout/weather_static_card.xml` and `app/src/main/java/sh/nym/irisglass/WeatherStaticCardPublisher.java`). + +## Notification framing (FeedReassembler) +`FEED_TX` notifications are treated as either: +- **FEED_TX frame (primary)**: + - `[0..3]=msgId(u32 LE)` + - `[4]=msgType(u8: 1=FULL_FEED, 2=PING)` + - `[5..6]=chunkIndex(u16 LE, 0-based)` + - `[7..8]=chunkCount(u16 LE)` + - `[9..]=payload bytes (UTF-8 slice)` +- **Binary V1**: `[type:1][msgId:4 LE][totalLen:4 LE][offset:4 LE][utf8-bytes...]` (type `0x01`=feed, `0x02`=ping) +- **Binary V2**: `[type:1][msgId:2 LE][chunkIndex:2 LE][totalChunks:2 LE][utf8-bytes...]` (type `0x01`=feed, `0x02`=ping) +- **ASCII fallback**: if the payload contains a `{...}` JSON object, it is parsed directly; `PING`/`ping` updates last ping time. + +## Build setup (Glass GDK LiveCard) +This project uses the Glass GDK `LiveCard` APIs directly (see `app/src/main/java/sh/nym/irisglass/HudService.java`) and renders the pinned HUD using `LiveCard.setViews(...)` with a `RemoteViews` layout (`app/src/main/res/layout/hud_live_card.xml`). + +If you prefer compiling directly against the Glass Development Kit Preview add-on: +1. Install the SDK add-on: **Google Inc. → Glass Development Kit Preview (API 19)** in the Android SDK Manager. +2. Set the module `compileSdk` to the add-on in `app/build.gradle` (or via Android Studio Project Structure). + +## Install on Glass +1. Enable developer mode + ADB on Glass. +2. Connect over USB and verify: + - `adb devices` +3. Install: + - `./gradlew :app:installDebug` +4. Launch “Iris Glass” on Glass. + +Expected behavior: +- Within ~15 seconds (scan + connect), the LiveCard status should reach “Connected”. +- On the first full feed, `FEED` logs a reassembled JSON string. +- The LiveCard displays the winner title/subtitle and a “+N” indicator for remaining items. +- Tap the LiveCard to open the full feed and swipe through cards (Google Now style). + +## Troubleshooting +- **No scan results**: + - Ensure Bluetooth is ON on Glass. + - Ensure the iPhone is advertising and within range. + - Some Android builds require location permission for BLE scan; this app declares `ACCESS_COARSE_LOCATION`. +- **Connected but no notifications**: + - Confirm the CCCD write succeeds in logcat (`BLE` tag, `onDescriptorWrite CCCD status=0`). + - Some peripherals won’t send until the CCCD is written; `setCharacteristicNotification(...)` alone is not enough. +- **Missing service/characteristic**: + - Verify the peripheral uses the exact UUIDs in `app/src/main/java/sh/nym/irisglass/Constants.java`. diff --git a/IrisGlass/app/.gitignore b/IrisGlass/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/IrisGlass/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/IrisGlass/app/build.gradle b/IrisGlass/app/build.gradle new file mode 100644 index 0000000..b46d38e --- /dev/null +++ b/IrisGlass/app/build.gradle @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'sh.nym.irisglass' + compileSdk 19 + + defaultConfig { + applicationId "sh.nym.irisglass" + minSdk 19 + targetSdk 19 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + // Glass Development Kit Preview (API 19) add-on provides com.google.android.glass.* classes. + // If you have it installed via the Android SDK Manager, this will pick up gdk.jar automatically. + def sdkDir = null + def lp = rootProject.file("local.properties") + if (lp.exists()) { + def p = new Properties() + lp.withInputStream { p.load(it) } + sdkDir = p.getProperty("sdk.dir") + } + if (sdkDir != null) { + def gdkJar = file("${sdkDir}/add-ons/addon-google_gdk-google-19/libs/gdk.jar") + if (gdkJar.exists()) { + compileOnly files(gdkJar) + } else { + logger.lifecycle("Glass GDK jar not found at: ${gdkJar}") + } + } else { + logger.lifecycle("No sdk.dir found; Glass GDK jar not configured.") + } +} diff --git a/IrisGlass/app/proguard-rules.pro b/IrisGlass/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/IrisGlass/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/IrisGlass/app/src/androidTest/java/sh/nym/irisglass/ExampleInstrumentedTest.java b/IrisGlass/app/src/androidTest/java/sh/nym/irisglass/ExampleInstrumentedTest.java new file mode 100644 index 0000000..e8d128c --- /dev/null +++ b/IrisGlass/app/src/androidTest/java/sh/nym/irisglass/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package sh.nym.irisglass; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("sh.nym.irisglass", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/IrisGlass/app/src/main/AndroidManifest.xml b/IrisGlass/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3315714 --- /dev/null +++ b/IrisGlass/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/ActionActivity.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/ActionActivity.java new file mode 100644 index 0000000..352216b --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/ActionActivity.java @@ -0,0 +1,147 @@ +package sh.nym.irisglass; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.AdapterView; + +import com.google.android.glass.widget.CardBuilder; +import com.google.android.glass.widget.CardScrollAdapter; +import com.google.android.glass.widget.CardScrollView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class ActionActivity extends Activity { + public static final String EXTRA_CARD_ID = "card_id"; + public static final String EXTRA_CARD_TITLE = "card_title"; + public static final String EXTRA_ACTIONS = "actions"; + public static final String EXTRA_ACTION = "action"; + + private CardScrollView cardScrollView; + private ActionAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + requestWindowFeature(Window.FEATURE_NO_TITLE); + super.onCreate(savedInstanceState); + + Intent i = getIntent(); + String cardId = i != null ? i.getStringExtra(EXTRA_CARD_ID) : null; + String cardTitle = i != null ? i.getStringExtra(EXTRA_CARD_TITLE) : null; + String[] actions = i != null ? i.getStringArrayExtra(EXTRA_ACTIONS) : null; + + List actionList = new ArrayList(); + if (actions != null) actionList.addAll(Arrays.asList(actions)); + if (actionList.isEmpty()) { + actionList.add("BACK"); + } + + cardScrollView = new CardScrollView(this); + adapter = new ActionAdapter(this, cardTitle, actionList); + cardScrollView.setAdapter(adapter); + cardScrollView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + String action = adapter.getActionAt(position); + if ("BACK".equals(action)) { + setResult(RESULT_CANCELED); + finish(); + return; + } + Intent r = new Intent(); + r.putExtra(EXTRA_CARD_ID, cardId); + r.putExtra(EXTRA_ACTION, action); + setResult(RESULT_OK, r); + finish(); + } + }); + + setContentView(cardScrollView); + } + + @Override + protected void onResume() { + super.onResume(); + cardScrollView.activate(); + } + + @Override + protected void onPause() { + cardScrollView.deactivate(); + super.onPause(); + } + + private static final class ActionAdapter extends CardScrollAdapter { + private final Activity activity; + private final String title; + private final List actions; + + ActionAdapter(Activity activity, String title, List actions) { + this.activity = activity; + this.title = title != null ? title : ""; + this.actions = actions != null ? actions : new ArrayList(); + } + + String getActionAt(int position) { + if (position < 0 || position >= actions.size()) return "BACK"; + return actions.get(position); + } + + @Override + public int getCount() { + return actions.size(); + } + + @Override + public Object getItem(int position) { + if (position < 0 || position >= actions.size()) return null; + return actions.get(position); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + String action = getActionAt(position); + String label = labelFor(action); + + CardBuilder b = new CardBuilder(activity, CardBuilder.Layout.MENU) + .setText(label); + if (title.length() > 0 && !"BACK".equals(action)) { + b.setFootnote(truncate(title, 30)); + } else if ("BACK".equals(action)) { + b.setFootnote("Tap to return"); + } + return b.getView(convertView, parent); + } + + @Override + public int getPosition(Object item) { + if (!(item instanceof String)) return -1; + String s = (String) item; + for (int i = 0; i < actions.size(); i++) { + if (s.equals(actions.get(i))) return i; + } + return -1; + } + + private static String labelFor(String action) { + if ("DISMISS".equals(action)) return "Dismiss"; + if ("SNOOZE_2H".equals(action)) return "Snooze 2 hours"; + if ("SNOOZE_24H".equals(action)) return "Snooze 24 hours"; + if ("SAVE".equals(action)) return "Save"; + if ("BACK".equals(action)) return "Back"; + return action; + } + + private static String truncate(String s, int maxChars) { + if (s == null) return ""; + if (s.length() <= maxChars) return s; + if (maxChars <= 1) return s.substring(0, maxChars); + return s.substring(0, maxChars - 1) + "\u2026"; + } + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/BleCentralClient.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleCentralClient.java new file mode 100644 index 0000000..eb1cf6d --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleCentralClient.java @@ -0,0 +1,536 @@ +package sh.nym.irisglass; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothProfile; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +public final class BleCentralClient { + public interface Callback { + void onStatus(String status, String lastErrorOrNull, boolean shouldRender); + void onConnected(); + void onPing(); + void onFeedJson(String json); + } + + private static final UUID CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + private static final long SCAN_TIMEOUT_MS = 10_000L; + private static final long CONNECT_TIMEOUT_MS = 12_000L; + + private final Context appContext; + private final Callback callback; + private final Handler handler; + private final BluetoothAdapter adapter; + private final FeedReassembler reassembler = new FeedReassembler(); + + private boolean scanning = false; + private BluetoothDevice lastDevice = null; + private BluetoothGatt gatt = null; + private boolean subscribed = false; + private int backoffMs = 1000; + + private long lastNotificationAtMs = 0L; + private long lastUnparsedLogAtMs = 0L; + + private final Runnable scanTimeoutRunnable = new Runnable() { + @Override + public void run() { + if (!scanning) return; + Log.i(Constants.TAG_BLE, "Scan timeout; restarting"); + stopScanInternal(); + scheduleReconnect("Scan timeout"); + } + }; + + private final Runnable connectTimeoutRunnable = new Runnable() { + @Override + public void run() { + if (gatt == null) return; + Log.w(Constants.TAG_BLE, "Connect timeout; disconnecting"); + closeGattInternal(); + scheduleReconnect("Connect timeout"); + } + }; + + private final BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() { + @Override + public void onLeScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) { + handler.post(new Runnable() { + @Override + public void run() { + onScanResult(device, rssi, scanRecord); + } + }); + } + }; + + private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() { + @Override + public void onConnectionStateChange(final BluetoothGatt g, final int status, final int newState) { + handler.post(new Runnable() { + @Override + public void run() { + handleConnectionStateChange(g, status, newState); + } + }); + } + + @Override + public void onServicesDiscovered(final BluetoothGatt g, final int status) { + handler.post(new Runnable() { + @Override + public void run() { + handleServicesDiscovered(g, status); + } + }); + } + + @Override + public void onDescriptorWrite(final BluetoothGatt g, final BluetoothGattDescriptor descriptor, final int status) { + handler.post(new Runnable() { + @Override + public void run() { + handleDescriptorWrite(g, descriptor, status); + } + }); + } + + @Override + public void onCharacteristicChanged(final BluetoothGatt g, final BluetoothGattCharacteristic characteristic) { + final byte[] value = characteristic.getValue(); + handler.post(new Runnable() { + @Override + public void run() { + handleCharacteristicChanged(characteristic.getUuid(), value); + } + }); + } + }; + + public BleCentralClient(Context context, Callback callback, Looper looper) { + this.appContext = context.getApplicationContext(); + this.callback = callback; + this.handler = new Handler(looper); + this.adapter = BluetoothAdapter.getDefaultAdapter(); + } + + public void start() { + handler.post(new Runnable() { + @Override + public void run() { + backoffMs = 1000; + startScanInternal("Start"); + } + }); + } + + public void rescan() { + handler.post(new Runnable() { + @Override + public void run() { + Log.i(Constants.TAG_BLE, "Rescan requested"); + closeGattInternal(); + backoffMs = 1000; + startScanInternal("Rescan"); + } + }); + } + + public void stop() { + handler.post(new Runnable() { + @Override + public void run() { + stopScanInternal(); + closeGattInternal(); + if (callback != null) callback.onStatus("Stopped", null, true); + } + }); + } + + private void onScanResult(BluetoothDevice device, int rssi, byte[] scanRecord) { + if (!scanning) return; + if (device == null) return; + + Advert adv = Advert.parse(scanRecord); + boolean nameMatch = (adv != null && Constants.PERIPHERAL_NAME_HINT.equals(adv.localName)) + || Constants.PERIPHERAL_NAME_HINT.equals(device.getName()); + boolean serviceMatch = adv != null && adv.hasService(Constants.SERVICE_UUID); + + if (!nameMatch && !serviceMatch) return; + + Log.i(Constants.TAG_BLE, "Found peripheral: addr=" + device.getAddress() + + " name=" + device.getName() + + " advName=" + (adv != null ? adv.localName : "null") + + " serviceMatch=" + serviceMatch + + " rssi=" + rssi); + + lastDevice = device; + stopScanInternal(); + connectInternal(device); + } + + private void scheduleReconnect(String reason) { + if (callback != null) callback.onStatus("Disconnected", reason, true); + int delay = backoffMs; + backoffMs = Math.min(30_000, backoffMs * 2); + + Log.i(Constants.TAG_BLE, "Reconnect in " + delay + "ms (" + reason + ")"); + handler.postDelayed(new Runnable() { + @Override + public void run() { + startScanInternal("Reconnect"); + } + }, delay); + } + + private void startScanInternal(String why) { + if (adapter == null) { + if (callback != null) callback.onStatus("BLE unsupported", "No BluetoothAdapter", true); + return; + } + if (!adapter.isEnabled()) { + if (callback != null) callback.onStatus("Bluetooth OFF", "Enable Bluetooth", true); + return; + } + if (scanning) return; + + subscribed = false; + if (callback != null) callback.onStatus("Scanning…", null, true); + Log.i(Constants.TAG_BLE, "startLeScan (" + why + ")"); + // Scan broadly; filter by advertised service UUID and/or local name hint in the callback. + scanning = startLeScanCompat(null, leScanCallback); + if (!scanning) { + if (callback != null) callback.onStatus("Scan failed", "startLeScan returned false", true); + scheduleReconnect("Scan failed"); + return; + } + + handler.removeCallbacks(scanTimeoutRunnable); + handler.postDelayed(scanTimeoutRunnable, SCAN_TIMEOUT_MS); + } + + private void stopScanInternal() { + if (!scanning) return; + scanning = false; + handler.removeCallbacks(scanTimeoutRunnable); + Log.i(Constants.TAG_BLE, "stopLeScan"); + stopLeScanCompat(leScanCallback); + } + + private void connectInternal(BluetoothDevice device) { + if (device == null) { + scheduleReconnect("No device"); + return; + } + if (callback != null) callback.onStatus("Connecting…", null, true); + Log.i(Constants.TAG_BLE, "connectGatt addr=" + device.getAddress()); + + closeGattInternal(); + subscribed = false; + + gatt = connectGattCompat(device, appContext, false, gattCallback); + if (gatt == null) { + scheduleReconnect("connectGatt returned null"); + return; + } + + handler.removeCallbacks(connectTimeoutRunnable); + handler.postDelayed(connectTimeoutRunnable, CONNECT_TIMEOUT_MS); + } + + private void handleConnectionStateChange(BluetoothGatt g, int status, int newState) { + if (gatt == null || g != gatt) return; + Log.i(Constants.TAG_BLE, "onConnectionStateChange status=" + status + " newState=" + newState); + + if (newState == BluetoothProfile.STATE_CONNECTED) { + handler.removeCallbacks(connectTimeoutRunnable); + backoffMs = 1000; + if (callback != null) { + callback.onStatus("Discovering…", null, true); + callback.onConnected(); + } + boolean ok = gatt.discoverServices(); + Log.i(Constants.TAG_BLE, "discoverServices=" + ok); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + handler.removeCallbacks(connectTimeoutRunnable); + closeGattInternal(); + scheduleReconnect("Disconnected"); + } + } + + private void handleServicesDiscovered(BluetoothGatt g, int status) { + if (gatt == null || g != gatt) return; + Log.i(Constants.TAG_BLE, "onServicesDiscovered status=" + status); + + if (status != BluetoothGatt.GATT_SUCCESS) { + closeGattInternal(); + scheduleReconnect("Service discovery failed: " + status); + return; + } + + BluetoothGattService service = gatt.getService(Constants.SERVICE_UUID); + if (service == null) { + closeGattInternal(); + scheduleReconnect("Missing service " + Constants.SERVICE_UUID_STR); + return; + } + + BluetoothGattCharacteristic feedTx = service.getCharacteristic(Constants.FEED_TX_UUID); + if (feedTx == null) { + closeGattInternal(); + scheduleReconnect("Missing characteristic " + Constants.FEED_TX_UUID_STR); + return; + } + + if (callback != null) callback.onStatus("Subscribing…", null, true); + Log.i(Constants.TAG_BLE, "Enabling notifications for FEED_TX"); + + boolean notifOk = gatt.setCharacteristicNotification(feedTx, true); + Log.i(Constants.TAG_BLE, "setCharacteristicNotification=" + notifOk); + + BluetoothGattDescriptor cccd = feedTx.getDescriptor(CCCD_UUID); + if (cccd == null) { + closeGattInternal(); + scheduleReconnect("Missing CCCD (0x2902)"); + return; + } + cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + boolean writeOk = gatt.writeDescriptor(cccd); + Log.i(Constants.TAG_BLE, "writeDescriptor(CCCD)=" + writeOk); + if (!writeOk) { + closeGattInternal(); + scheduleReconnect("CCCD write failed to start"); + } + } + + private void handleDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int status) { + if (gatt == null || g != gatt) return; + if (descriptor == null) return; + if (!CCCD_UUID.equals(descriptor.getUuid())) return; + + Log.i(Constants.TAG_BLE, "onDescriptorWrite CCCD status=" + status); + if (status == BluetoothGatt.GATT_SUCCESS) { + subscribed = true; + if (callback != null) callback.onStatus("Connected", null, true); + } else { + closeGattInternal(); + scheduleReconnect("CCCD write failed: " + status); + } + } + + private void handleCharacteristicChanged(UUID uuid, byte[] value) { + if (!Constants.FEED_TX_UUID.equals(uuid)) return; + lastNotificationAtMs = System.currentTimeMillis(); + + FeedReassembler.Result result = reassembler.onNotification(value, lastNotificationAtMs); + if (result == null) { + // Diagnostic: log occasionally so we can infer the framing coming from iPhone. + if (lastNotificationAtMs - lastUnparsedLogAtMs > 1000L) { + lastUnparsedLogAtMs = lastNotificationAtMs; + String hint = ""; + if (value != null && value.length >= 9) { + long msgId = ((long) (value[0] & 0xFF)) + | ((long) (value[1] & 0xFF) << 8) + | ((long) (value[2] & 0xFF) << 16) + | ((long) (value[3] & 0xFF) << 24); + int type = value[4] & 0xFF; + int idx = (value[5] & 0xFF) | ((value[6] & 0xFF) << 8); + int cnt = (value[7] & 0xFF) | ((value[8] & 0xFF) << 8); + hint = " msgId=" + (msgId & 0xFFFFFFFFL) + " type=" + type + " chunk=" + idx + "/" + cnt; + } + Log.d(Constants.TAG_FEED, "Unparsed notify len=" + (value != null ? value.length : 0) + hint + " hex=" + hexPrefix(value, 16)); + } + return; + } + + if (result.isPing) { + Log.d(Constants.TAG_FEED, "PING"); + if (callback != null) callback.onPing(); + return; + } + if (result.jsonOrNull != null) { + Log.i(Constants.TAG_FEED, "Reassembled JSON (" + result.jsonOrNull.length() + " bytes)"); + Log.i(Constants.TAG_FEED, "RAW_JSON_BEGIN"); + logLarge(Constants.TAG_FEED, result.jsonOrNull); + Log.i(Constants.TAG_FEED, "RAW_JSON_END"); + if (callback != null) callback.onFeedJson(result.jsonOrNull); + } + } + + private void closeGattInternal() { + subscribed = false; + handler.removeCallbacks(connectTimeoutRunnable); + if (gatt != null) { + try { + gatt.disconnect(); + } catch (Throwable ignored) { + } + try { + gatt.close(); + } catch (Throwable ignored) { + } + gatt = null; + } + } + + @SuppressLint("MissingPermission") + private static boolean startLeScanCompat(UUID[] uuids, BluetoothAdapter.LeScanCallback callback) { + BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter(); + if (a == null) return false; + try { + if (uuids == null || uuids.length == 0) { + return a.startLeScan(callback); + } + return a.startLeScan(uuids, callback); + } catch (Throwable t) { + Log.w(Constants.TAG_BLE, "startLeScan failed: " + t); + return false; + } + } + + @SuppressLint("MissingPermission") + private static void stopLeScanCompat(BluetoothAdapter.LeScanCallback callback) { + BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter(); + if (a == null) return; + try { + a.stopLeScan(callback); + } catch (Throwable t) { + Log.w(Constants.TAG_BLE, "stopLeScan failed: " + t); + } + } + + @SuppressLint("MissingPermission") + private static BluetoothGatt connectGattCompat(BluetoothDevice device, Context context, boolean autoConnect, BluetoothGattCallback callback) { + try { + return device.connectGatt(context, autoConnect, callback); + } catch (Throwable t) { + Log.w(Constants.TAG_BLE, "connectGatt failed: " + t); + return null; + } + } + + private static void logLarge(String tag, String s) { + if (s == null) { + Log.d(tag, "null"); + return; + } + // Logcat truncates long lines; split into chunks. + final int max = 3500; + int i = 0; + while (i < s.length()) { + int end = Math.min(s.length(), i + max); + Log.d(tag, s.substring(i, end)); + i = end; + } + } + + private static final class Advert { + final String localName; + final List services; + + private Advert(String localName, List services) { + this.localName = localName; + this.services = services; + } + + boolean hasService(UUID uuid) { + if (services == null || uuid == null) return false; + for (int i = 0; i < services.size(); i++) { + if (uuid.equals(services.get(i))) return true; + } + return false; + } + + static Advert parse(byte[] scanRecord) { + if (scanRecord == null) return null; + String name = null; + UUID[] found = new UUID[0]; + + int index = 0; + while (index < scanRecord.length) { + int len = scanRecord[index] & 0xFF; + if (len == 0) break; + int typeIndex = index + 1; + if (typeIndex >= scanRecord.length) break; + int type = scanRecord[typeIndex] & 0xFF; + int dataIndex = index + 2; + int dataLen = len - 1; + if (dataIndex + dataLen > scanRecord.length) break; + + if (type == 0x08 || type == 0x09) { // short/complete local name + try { + name = new String(scanRecord, dataIndex, dataLen, "UTF-8"); + } catch (Throwable ignored) { + } + } else if (type == 0x06 || type == 0x07) { // 128-bit service UUIDs + int uuids = dataLen / 16; + UUID[] tmp = new UUID[uuids]; + for (int i = 0; i < uuids; i++) { + int off = dataIndex + (i * 16); + tmp[i] = uuidFrom128LittleEndian(scanRecord, off); + } + found = concat(found, tmp); + } else if (type == 0x21) { // Service Data - 128-bit UUID + if (dataLen >= 16) { + UUID uuid = uuidFrom128LittleEndian(scanRecord, dataIndex); + found = concat(found, new UUID[]{uuid}); + } + } + + index = index + len + 1; + } + + return new Advert(name, Arrays.asList(found)); + } + + private static UUID[] concat(UUID[] a, UUID[] b) { + if (a == null || a.length == 0) return b; + if (b == null || b.length == 0) return a; + UUID[] out = new UUID[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + private static UUID uuidFrom128LittleEndian(byte[] b, int off) { + // BLE advertising stores 128-bit UUID little-endian. + long lsb = 0; + long msb = 0; + for (int i = 0; i < 8; i++) { + lsb = (lsb << 8) | (b[off + 15 - i] & 0xFF); + } + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (b[off + 7 - i] & 0xFF); + } + return new UUID(msb, lsb); + } + } + + private static String hexPrefix(byte[] b, int max) { + if (b == null) return ""; + int n = Math.min(b.length, max); + StringBuilder sb = new StringBuilder(n * 3); + for (int i = 0; i < n; i++) { + int v = b[i] & 0xFF; + if (i > 0) sb.append(' '); + if (v < 0x10) sb.append('0'); + sb.append(Integer.toHexString(v)); + } + if (b.length > n) sb.append(" …"); + return sb.toString(); + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java new file mode 100644 index 0000000..6767f48 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java @@ -0,0 +1,125 @@ +package sh.nym.irisglass; + +import android.app.Service; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.HandlerThread; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.json.JSONException; + +import java.util.List; + +public final class BleLinkService extends Service implements BleCentralClient.Callback { + private static final String PREF_KEY_LAST_WINNER_ID = "winner_last_id"; + + private HandlerThread bleThread; + private BleCentralClient client; + + @Override + public void onCreate() { + super.onCreate(); + bleThread = new HandlerThread("ble-link"); + bleThread.start(); + client = new BleCentralClient(getApplicationContext(), this, bleThread.getLooper()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + if (Constants.ACTION_RESCAN.equals(action)) { + client.rescan(); + } else { + client.start(); + } + return START_STICKY; + } + + @Override + public void onDestroy() { + try { + if (client != null) client.stop(); + } catch (Throwable ignored) { + } + if (bleThread != null) { + bleThread.quit(); + bleThread = null; + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onStatus(String status, String lastErrorOrNull, boolean shouldRender) { + Log.i(Constants.TAG_BLE, "Status: " + status + (lastErrorOrNull != null ? (" (" + lastErrorOrNull + ")") : "")); + Intent i = new Intent(Constants.ACTION_BLE_STATUS); + i.putExtra(Constants.EXTRA_BLE_STATUS, status); + i.putExtra(Constants.EXTRA_BLE_ERROR, lastErrorOrNull); + sendBroadcast(i); + } + + @Override + public void onConnected() { + Intent i = new Intent(Constants.ACTION_BLE_STATUS); + i.putExtra(Constants.EXTRA_BLE_STATUS, "Connected"); + sendBroadcast(i); + } + + @Override + public void onPing() { + } + + @Override + public void onFeedJson(String json) { + if (json == null) return; + + try { + FeedEnvelope env = FeedParser.parseEnvelope(json); + HudState.get().setFeed(env.items, env.meta, true); + maybeNudgeWinnerChanged(env); + } catch (JSONException e) { + Log.w(Constants.TAG_FEED, "Parse error: " + e); + Intent i = new Intent(Constants.ACTION_BLE_STATUS); + i.putExtra(Constants.EXTRA_BLE_STATUS, "Connected"); + i.putExtra(Constants.EXTRA_BLE_ERROR, "Feed parse error: " + e.getMessage()); + sendBroadcast(i); + } + } + + private void maybeNudgeWinnerChanged(FeedEnvelope env) { + if (env == null) return; + + String winnerId = env.meta != null ? env.meta.winnerId : null; + if (winnerId == null || winnerId.length() == 0) { + List active = env.activeItems(); + if (!active.isEmpty()) { + FeedItem first = active.get(0); + if (first != null) winnerId = first.id; + } + } + if (winnerId == null || winnerId.length() == 0) return; + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String lastWinnerId = prefs.getString(PREF_KEY_LAST_WINNER_ID, null); + if (lastWinnerId == null) { + prefs.edit().putString(PREF_KEY_LAST_WINNER_ID, winnerId).apply(); + Log.i(Constants.TAG_HUD, "Winner nudge: seeded last winner id=" + winnerId); + return; + } + if (winnerId.equals(lastWinnerId)) return; + + prefs.edit().putString(PREF_KEY_LAST_WINNER_ID, winnerId).apply(); + Log.i(Constants.TAG_HUD, "Winner nudge: winner changed " + lastWinnerId + " -> " + winnerId); + + Intent i = new Intent(getApplicationContext(), HudService.class); + i.setAction(Constants.ACTION_WINNER_CHANGED); + i.putExtra(Constants.EXTRA_WINNER_ID, winnerId); + startService(i); + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/BucketType.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/BucketType.java new file mode 100644 index 0000000..640cae4 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/BucketType.java @@ -0,0 +1,14 @@ +package sh.nym.irisglass; + +public final class BucketType { + private BucketType() {} + + public static final String RIGHT_NOW = "RIGHT_NOW"; + public static final String FYI = "FYI"; + + public static final String[] ALL = new String[] { + RIGHT_NOW, + FYI + }; +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java new file mode 100644 index 0000000..4111648 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java @@ -0,0 +1,30 @@ +package sh.nym.irisglass; + +import java.util.UUID; + +public final class Constants { + private Constants() {} + + public static final String TAG_BLE = "BLE"; + public static final String TAG_FEED = "FEED"; + public static final String TAG_HUD = "HUD"; + + public static final String SERVICE_UUID_STR = "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; + public static final String FEED_TX_UUID_STR = "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; + public static final String CONTROL_RX_UUID_STR = "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; + + public static final UUID SERVICE_UUID = UUID.fromString(SERVICE_UUID_STR); + public static final UUID FEED_TX_UUID = UUID.fromString(FEED_TX_UUID_STR); + public static final UUID CONTROL_RX_UUID = UUID.fromString(CONTROL_RX_UUID_STR); + + public static final String PERIPHERAL_NAME_HINT = "GlassNow"; + + public static final String ACTION_START_HUD = "sh.nym.irisglass.action.START_HUD"; + public static final String ACTION_RESCAN = "sh.nym.irisglass.action.RESCAN"; + public static final String ACTION_WINNER_CHANGED = "sh.nym.irisglass.action.WINNER_CHANGED"; + public static final String EXTRA_WINNER_ID = "extra_winner_id"; + + public static final String ACTION_BLE_STATUS = "sh.nym.irisglass.action.BLE_STATUS"; + public static final String EXTRA_BLE_STATUS = "extra_ble_status"; + public static final String EXTRA_BLE_ERROR = "extra_ble_error"; +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedActivity.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedActivity.java new file mode 100644 index 0000000..0681648 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedActivity.java @@ -0,0 +1,140 @@ +package sh.nym.irisglass; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.widget.AdapterView; +import android.widget.Toast; + +import com.google.android.glass.widget.CardScrollView; + +import java.util.ArrayList; +import java.util.List; + +public final class FeedActivity extends Activity { + private static final int REQ_MENU = 1; + + private CardScrollView cardScrollView; + private FeedAdapter adapter; + private SuppressionStore suppressionStore; + private FeedItem selectedItem; + + private final HudState.Listener hudListener = new HudState.Listener() { + @Override + public void onHudStateChanged(HudState.Snapshot snapshot, boolean shouldRender) { + // FeedActivity should always update while visible. + adapter.setItems(buildVisibleItems(snapshot)); + try { + cardScrollView.setSelection(0); + } catch (Throwable ignored) { + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + requestWindowFeature(Window.FEATURE_NO_TITLE); + super.onCreate(savedInstanceState); + + suppressionStore = new SuppressionStore(this); + + cardScrollView = new CardScrollView(this); + adapter = new FeedAdapter(this, makeWaitingCard()); + cardScrollView.setAdapter(adapter); + cardScrollView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + onCardTapped(position); + } + }); + setContentView(cardScrollView); + } + + @Override + protected void onResume() { + super.onResume(); + cardScrollView.activate(); + HudState.get().addListener(hudListener); + adapter.setItems(buildVisibleItems(HudState.get().snapshot())); + } + + @Override + protected void onPause() { + try { + HudState.get().removeListener(hudListener); + } catch (Throwable ignored) { + } + cardScrollView.deactivate(); + super.onPause(); + } + + private void onCardTapped(int position) { + Object obj = adapter.getItem(position); + if (!(obj instanceof FeedItem)) return; + selectedItem = (FeedItem) obj; + startActivityForResult(MenuActivity.newIntent(this, selectedItem), REQ_MENU); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, android.content.Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != REQ_MENU || resultCode != RESULT_OK || data == null) return; + + String cardId = data.getStringExtra(MenuActivity.EXTRA_CARD_ID); + String action = data.getStringExtra(MenuActivity.EXTRA_ACTION); + if (cardId == null || action == null) return; + + long now = System.currentTimeMillis() / 1000L; + if ("DISMISS".equals(action)) { + suppressionStore.setSuppressed(cardId, now + (10L * 365L * 24L * 60L * 60L)); + adapter.removeById(cardId); + } else if ("SNOOZE_2H".equals(action)) { + suppressionStore.setSuppressed(cardId, now + 2L * 60L * 60L); + adapter.removeById(cardId); + } else if ("SNOOZE_24H".equals(action)) { + suppressionStore.setSuppressed(cardId, now + 24L * 60L * 60L); + adapter.removeById(cardId); + } else if ("SAVE".equals(action)) { + Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show(); + } + + if (adapter.getCount() == 0) { + adapter.setItems(makeWaitingCard()); + } + } + + private List makeWaitingCard() { + ArrayList items = new ArrayList(); + items.add(new FeedItem( + "local:empty", + FeedItemType.INFO, + "No cards", + "Waiting for feed…", + 0.0, + 1, + "", + new ArrayList() + )); + return items; + } + + private List buildVisibleItems(HudState.Snapshot snapshot) { + if (snapshot == null) return makeWaitingCard(); + FeedEnvelope env = new FeedEnvelope(1, 0L, snapshot.items, snapshot.meta); + List items = env.activeItems(); + if (items.isEmpty()) return makeWaitingCard(); + + long now = System.currentTimeMillis() / 1000L; + ArrayList filtered = new ArrayList(); + for (int i = 0; i < items.size(); i++) { + FeedItem it = items.get(i); + if (it == null) continue; + if (it.id != null && suppressionStore.isSuppressed(it.id, now)) continue; + filtered.add(it); + } + if (filtered.isEmpty()) return makeWaitingCard(); + return filtered; + } + +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedAdapter.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedAdapter.java new file mode 100644 index 0000000..f24dc23 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedAdapter.java @@ -0,0 +1,144 @@ +package sh.nym.irisglass; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import com.google.android.glass.widget.CardBuilder; +import com.google.android.glass.widget.CardScrollAdapter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public final class FeedAdapter extends CardScrollAdapter { + private final Context context; + private final ArrayList items; + + public FeedAdapter(Context context, List items) { + this.context = context.getApplicationContext(); + this.items = new ArrayList(); + if (items != null) this.items.addAll(items); + sortInPlace(this.items); + } + + public List getItems() { + return items; + } + + public boolean removeById(String id) { + if (id == null) return false; + for (int i = 0; i < items.size(); i++) { + FeedItem it = items.get(i); + if (it != null && id.equals(it.id)) { + items.remove(i); + notifyDataSetChanged(); + return true; + } + } + return false; + } + + public void setItems(List newItems) { + items.clear(); + if (newItems != null) items.addAll(newItems); + sortInPlace(items); + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return items.size(); + } + + @Override + public Object getItem(int position) { + if (position < 0 || position >= items.size()) return null; + return items.get(position); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + FeedItem item = (FeedItem) getItem(position); + if (item == null) { + return new CardBuilder(context, CardBuilder.Layout.TEXT) + .setText("No cards") + .setFootnote("Waiting for feed…") + .getView(convertView, parent); + } + + String title = truncate(item.title, 26); + String subtitle = truncate(item.subtitle, 30); + + // Keep bucket hint subtle by appending to subtitle if it fits. + String bucket = item.bucket != null ? item.bucket : ""; + if (bucket.length() > 0) { + String suffix = " · " + bucket; + if (subtitle.length() + suffix.length() <= 30) { + subtitle = subtitle + suffix; + } + } + + return new CardBuilder(context, CardBuilder.Layout.TEXT) + .setText(title) + .setFootnote(subtitle) + .getView(convertView, parent); + } + + @Override + public int getPosition(Object item) { + if (!(item instanceof FeedItem)) return AdapterViewCompat.INVALID_POSITION; + FeedItem it = (FeedItem) item; + for (int i = 0; i < items.size(); i++) { + FeedItem cur = items.get(i); + if (cur == null || it.id == null) continue; + if (it.id.equals(cur.id)) return i; + } + return AdapterViewCompat.INVALID_POSITION; + } + + private static void sortInPlace(List items) { + if (items == null) return; + Collections.sort(items, new Comparator() { + @Override + public int compare(FeedItem a, FeedItem b) { + if (a == b) return 0; + if (a == null) return 1; + if (b == null) return -1; + + int ba = bucketRank(a.bucket); + int bb = bucketRank(b.bucket); + if (ba != bb) return ba - bb; + + // priority desc + if (a.priority != b.priority) { + return a.priority < b.priority ? 1 : -1; + } + + String ida = a.id != null ? a.id : ""; + String idb = b.id != null ? b.id : ""; + return ida.compareTo(idb); + } + }); + } + + private static int bucketRank(String bucket) { + if (BucketType.RIGHT_NOW.equals(bucket)) return 0; + if ("NEARBY".equals(bucket)) return 1; + if (BucketType.FYI.equals(bucket)) return 2; + return 3; + } + + private static String truncate(String s, int maxChars) { + if (s == null) return ""; + if (s.length() <= maxChars) return s; + if (maxChars <= 1) return s.substring(0, maxChars); + return s.substring(0, maxChars - 1) + "\u2026"; + } + + // Avoid depending on AdapterView.INVALID_POSITION (not available on some older builds). + private static final class AdapterViewCompat { + static final int INVALID_POSITION = -1; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedEnvelope.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedEnvelope.java new file mode 100644 index 0000000..112036d --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedEnvelope.java @@ -0,0 +1,29 @@ +package sh.nym.irisglass; + +import java.util.ArrayList; +import java.util.List; + +public final class FeedEnvelope { + public final int schema; + public final long generatedAtEpochSeconds; + public final List items; + public final FeedMeta meta; + + public FeedEnvelope(int schema, long generatedAtEpochSeconds, List items, FeedMeta meta) { + this.schema = schema; + this.generatedAtEpochSeconds = generatedAtEpochSeconds; + this.items = items != null ? items : new ArrayList(); + this.meta = meta; + } + + public List activeItems() { + ArrayList out = new ArrayList<>(); + for (int i = 0; i < items.size(); i++) { + FeedItem it = items.get(i); + if (it == null) continue; + if (it.ttlSec > 0) out.add(it); + } + return out; + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItem.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItem.java new file mode 100644 index 0000000..defa080 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItem.java @@ -0,0 +1,53 @@ +package sh.nym.irisglass; + +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public final class FeedItem { + public final String id; + public final String type; + public final String title; + public final String subtitle; + public final double priority; + public final int ttlSec; + public final String bucket; + public final List actions; + public final JSONObject raw; + + public FeedItem( + String id, + String type, + String title, + String subtitle, + double priority, + int ttlSec, + String bucket, + List actions + ) { + this(id, type, title, subtitle, priority, ttlSec, bucket, actions, null); + } + + public FeedItem( + String id, + String type, + String title, + String subtitle, + double priority, + int ttlSec, + String bucket, + List actions, + JSONObject raw + ) { + this.id = id; + this.type = type; + this.title = title; + this.subtitle = subtitle; + this.priority = priority; + this.ttlSec = ttlSec; + this.bucket = bucket; + this.actions = actions != null ? actions : new ArrayList(); + this.raw = raw; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItemType.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItemType.java new file mode 100644 index 0000000..f3256cc --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItemType.java @@ -0,0 +1,25 @@ +package sh.nym.irisglass; + +public final class FeedItemType { + private FeedItemType() {} + + public static final String WEATHER_ALERT = "WEATHER_ALERT"; + public static final String WEATHER_WARNING = "WEATHER_WARNING"; + public static final String TRANSIT = "TRANSIT"; + public static final String POI_NEARBY = "POI_NEARBY"; + public static final String INFO = "INFO"; + public static final String NOW_PLAYING = "NOW_PLAYING"; + public static final String CURRENT_WEATHER = "CURRENT_WEATHER"; + public static final String ALL_QUIET = "ALL_QUIET"; + + public static final String[] ALL = new String[] { + WEATHER_ALERT, + WEATHER_WARNING, + TRANSIT, + POI_NEARBY, + INFO, + NOW_PLAYING, + CURRENT_WEATHER, + ALL_QUIET + }; +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedMeta.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedMeta.java new file mode 100644 index 0000000..97ae389 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedMeta.java @@ -0,0 +1,12 @@ +package sh.nym.irisglass; + +public final class FeedMeta { + public final String winnerId; + public final int unreadCount; + + public FeedMeta(String winnerId, int unreadCount) { + this.winnerId = winnerId; + this.unreadCount = unreadCount; + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModel.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModel.java new file mode 100644 index 0000000..ea52bcd --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModel.java @@ -0,0 +1,14 @@ +package sh.nym.irisglass; + +public final class FeedModel { + public final String winnerTitle; + public final String winnerSubtitle; + public final int moreCount; + + public FeedModel(String winnerTitle, String winnerSubtitle, int moreCount) { + this.winnerTitle = winnerTitle; + this.winnerSubtitle = winnerSubtitle; + this.moreCount = moreCount; + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModelBuilder.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModelBuilder.java new file mode 100644 index 0000000..e4e5c07 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModelBuilder.java @@ -0,0 +1,38 @@ +package sh.nym.irisglass; + +import java.util.List; + +public final class FeedModelBuilder { + private FeedModelBuilder() {} + + public static FeedModel fromEnvelope(FeedEnvelope env) { + if (env == null) return new FeedModel("Glass Now online", "BLE connected", 0); + + List active = env.activeItems(); + if (active.isEmpty()) return new FeedModel("Glass Now online", "BLE connected", 0); + + String winnerId = env.meta != null ? env.meta.winnerId : null; + int unreadCount = env.meta != null ? env.meta.unreadCount : -1; + + FeedItem winner = active.get(0); + if (winnerId != null && winnerId.length() > 0) { + for (int i = 0; i < active.size(); i++) { + FeedItem it = active.get(i); + if (it == null || it.id == null) continue; + if (winnerId.equals(it.id)) { + winner = it; + break; + } + } + } + + String title = winner != null && winner.title != null && winner.title.length() > 0 ? winner.title : "Glass Now online"; + String subtitle = winner != null && winner.subtitle != null && winner.subtitle.length() > 0 ? winner.subtitle : "BLE connected"; + + int moreCount = Math.max(0, active.size() - 1); + if (unreadCount >= 0) moreCount = Math.max(0, unreadCount - 1); + + return new FeedModel(title, subtitle, moreCount); + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedParser.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedParser.java new file mode 100644 index 0000000..9cdfbef --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedParser.java @@ -0,0 +1,63 @@ +package sh.nym.irisglass; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; + +public final class FeedParser { + private FeedParser() {} + + public static FeedEnvelope parseEnvelope(String json) throws JSONException { + if (json == null) throw new JSONException("json is null"); + + JSONObject root = new JSONObject(json); + int schema = root.optInt("schema", -1); + if (schema != 1) { + Log.w(Constants.TAG_FEED, "Unexpected schema=" + schema); + } + + long generatedAt = root.optLong("generated_at", 0L); + + FeedMeta meta = null; + JSONObject metaObj = root.optJSONObject("meta"); + if (metaObj != null) { + String winnerId = metaObj.optString("winner_id", null); + int unreadCount = metaObj.optInt("unread_count", -1); + meta = new FeedMeta(winnerId, unreadCount); + } + + ArrayList items = new ArrayList(); + JSONArray feed = root.optJSONArray("feed"); + if (feed != null) { + for (int i = 0; i < feed.length(); i++) { + JSONObject card = feed.optJSONObject(i); + if (card == null) continue; + + int ttlSec = card.optInt("ttl_sec", 0); + String id = card.optString("id", null); + String type = card.optString("type", ""); + String title = card.optString("title", ""); + String subtitle = card.optString("subtitle", ""); + double priority = card.optDouble("priority", 0.0); + String bucket = card.optString("bucket", ""); + + ArrayList actions = new ArrayList(); + JSONArray actionArray = card.optJSONArray("actions"); + if (actionArray != null) { + for (int a = 0; a < actionArray.length(); a++) { + String act = actionArray.optString(a, null); + if (act != null && act.length() > 0) actions.add(act); + } + } + + items.add(new FeedItem(id, type, title, subtitle, priority, ttlSec, bucket, actions, card)); + } + } + + return new FeedEnvelope(schema, generatedAt, items, meta); + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedReassembler.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedReassembler.java new file mode 100644 index 0000000..1d5cd46 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/FeedReassembler.java @@ -0,0 +1,519 @@ +package sh.nym.irisglass; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +public final class FeedReassembler { + public static final class Result { + public final boolean isPing; + public final String jsonOrNull; + + private Result(boolean isPing, String jsonOrNull) { + this.isPing = isPing; + this.jsonOrNull = jsonOrNull; + } + + public static Result ping() { + return new Result(true, null); + } + + public static Result json(String json) { + return new Result(false, json); + } + } + + private static final long ASSEMBLY_TIMEOUT_MS = 15000L; + private static final int STREAM_MAX_BYTES = 64 * 1024; + private static final int FRAME_HEADER_LEN = 9; + private static final int MAX_CHUNKS = 2048; + private static final int MAX_REASSEMBLED_BYTES = 256 * 1024; + + private static final int MSGTYPE_FULL_FEED = 0x01; + private static final int MSGTYPE_PING = 0x02; + private static final int LEGACY_TYPE_FEED = 0x01; + private static final int LEGACY_TYPE_PING = 0x02; + + private static final class Assembly { + final int msgId; + final int totalLen; + final long createdAtMs; + long lastUpdateMs; + final TreeMap chunksByOffset = new TreeMap(); + + Assembly(int msgId, int totalLen, long nowMs) { + this.msgId = msgId; + this.totalLen = totalLen; + this.createdAtMs = nowMs; + this.lastUpdateMs = nowMs; + } + + int receivedBytes() { + int sum = 0; + for (Map.Entry e : chunksByOffset.entrySet()) { + sum += e.getValue().length; + } + return sum; + } + + byte[] toByteArrayIfComplete() { + if (totalLen <= 0) return null; + if (receivedBytes() < totalLen) return null; + + byte[] out = new byte[totalLen]; + for (Map.Entry e : chunksByOffset.entrySet()) { + int offset = e.getKey(); + byte[] chunk = e.getValue(); + if (offset < 0 || offset >= out.length) return null; + int copyLen = Math.min(chunk.length, out.length - offset); + System.arraycopy(chunk, 0, out, offset, copyLen); + } + return out; + } + } + + private final Object lock = new Object(); + private final HashMap assemblies = new HashMap(); + private StreamAssembly stream; + private final HashMap chunkAssemblies = new HashMap(); + private long lastParseErrorLogAtMs = 0L; + private long lastChunkLogAtMs = 0L; + + private static final class StreamAssembly { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(); + long lastUpdateMs; + + StreamAssembly(long nowMs) { + this.lastUpdateMs = nowMs; + } + } + + private static final class ChunkAssembly { + final int msgId; + final int chunkCount; + final long createdAtMs; + long lastUpdateMs; + final byte[][] chunks; + int receivedChunks; + + ChunkAssembly(int msgId, int chunkCount, long nowMs) { + this.msgId = msgId; + this.chunkCount = chunkCount; + this.createdAtMs = nowMs; + this.lastUpdateMs = nowMs; + this.chunks = new byte[chunkCount][]; + this.receivedChunks = 0; + } + + boolean isComplete() { + for (int i = 0; i < chunkCount; i++) { + if (chunks[i] == null) return false; + } + return true; + } + + byte[] concat() { + int total = 0; + for (int i = 0; i < chunkCount; i++) { + total += chunks[i].length; + } + if (total > MAX_REASSEMBLED_BYTES) return null; + byte[] out = new byte[total]; + int pos = 0; + for (int i = 0; i < chunkCount; i++) { + byte[] c = chunks[i]; + System.arraycopy(c, 0, out, pos, c.length); + pos += c.length; + } + return out; + } + } + + public Result onNotification(byte[] value, long nowMs) { + if (value == null || value.length == 0) return null; + + // Try the specified FEED_TX frame format first; if it matches, do NOT fall back. + if (looksLikeFeedTxFrame(value)) { + return tryFeedTxFrame(value, nowMs); + } + + Result simple = trySimpleText(value); + if (simple != null) return simple; + + Result vA = tryBinaryV1(value, nowMs); + if (vA != null) return vA; + + Result vB = tryBinaryV2(value, nowMs); + if (vB != null) return vB; + + Result stream = tryJsonStream(value, nowMs); + if (stream != null) return stream; + + return null; + } + + // FEED_TX notify payload frame: + // [0..3] msgId (u32 LE) + // [4] msgType (u8): 1=FULL_FEED, 2=PING + // [5..6] chunkIndex (u16 LE, 0-based) + // [7..8] chunkCount (u16 LE, >=1) + // [9..] payload (UTF-8 slice; empty for PING) + private Result tryFeedTxFrame(byte[] value, long nowMs) { + if (value.length < FRAME_HEADER_LEN) return null; + + int msgId = readLe32(value, 0); + int msgType = value[4] & 0xFF; + int chunkIndex = readLe16(value, 5); + int chunkCount = readLe16(value, 7); + + if (msgType != MSGTYPE_FULL_FEED && msgType != MSGTYPE_PING) { + logParseErrorRateLimited(nowMs, "bad msgType=" + msgType + " msgId=" + u32(msgId), value); + return null; + } + if (chunkCount <= 0 || chunkCount > MAX_CHUNKS) { + logParseErrorRateLimited(nowMs, "bad chunkCount=" + chunkCount + " msgId=" + u32(msgId), value); + return null; + } + if (chunkIndex < 0 || chunkIndex >= chunkCount) { + logParseErrorRateLimited(nowMs, "bad chunkIndex=" + chunkIndex + " chunkCount=" + chunkCount + " msgId=" + u32(msgId), value); + return null; + } + + if (msgType == MSGTYPE_PING) { + Log.d(Constants.TAG_FEED, "PING msgId=" + u32(msgId)); + return Result.ping(); + } + + byte[] chunk = new byte[Math.max(0, value.length - FRAME_HEADER_LEN)]; + if (chunk.length > 0) { + System.arraycopy(value, FRAME_HEADER_LEN, chunk, 0, chunk.length); + } + + synchronized (lock) { + pruneChunkAssembliesLocked(nowMs); + + ChunkAssembly a = chunkAssemblies.get(msgId); + if (a == null || a.chunkCount != chunkCount) { + if (a != null && a.chunkCount != chunkCount) { + logParseErrorRateLimited(nowMs, "chunkCount changed msgId=" + u32(msgId) + " was=" + a.chunkCount + " now=" + chunkCount, value); + } + a = new ChunkAssembly(msgId, chunkCount, nowMs); + chunkAssemblies.put(msgId, a); + Log.i(Constants.TAG_FEED, "Start FULL_FEED msgId=" + u32(msgId) + " chunkCount=" + chunkCount); + } + + a.lastUpdateMs = nowMs; + if (a.chunks[chunkIndex] == null) a.receivedChunks++; + a.chunks[chunkIndex] = chunk; + logChunkProgressRateLimited(nowMs, u32(msgId), chunkIndex, chunkCount, chunk.length, a.receivedChunks); + + if (a.isComplete()) { + chunkAssemblies.remove(msgId); + byte[] out = a.concat(); + return jsonResultOrNull(nowMs, msgId, out, "FEED_TX"); + } + } + return null; + } + + private Result trySimpleText(byte[] value) { + String s; + try { + s = new String(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return null; + } + if (s.length() == 0) return null; + + if (s.startsWith("PING") || s.startsWith("ping")) { + return Result.ping(); + } + + int first = s.indexOf('{'); + int last = s.lastIndexOf('}'); + if (first >= 0 && last > first) { + String json = s.substring(first, last + 1); + return Result.json(json); + } + return null; + } + + private Result tryJsonStream(byte[] value, long nowMs) { + synchronized (lock) { + if (stream != null && (nowMs - stream.lastUpdateMs) > ASSEMBLY_TIMEOUT_MS) { + stream = null; + } + + if (stream == null) { + int start = indexOfByte(value, (byte) '{'); + if (start < 0) return null; + stream = new StreamAssembly(nowMs); + stream.buf.write(value, start, value.length - start); + stream.lastUpdateMs = nowMs; + } else { + // Assumption: per-message header is only on the first chunk; subsequent chunks are raw UTF-8 JSON bytes. + stream.buf.write(value, 0, value.length); + stream.lastUpdateMs = nowMs; + } + + if (stream.buf.size() > STREAM_MAX_BYTES) { + Log.w(Constants.TAG_FEED, "Stream assembly exceeded max bytes; dropping"); + stream = null; + return null; + } + + byte[] buf = stream.buf.toByteArray(); + int end = findJsonObjectEnd(buf); + if (end >= 0) { + try { + String json = new String(buf, 0, end + 1, "UTF-8"); + this.stream = null; + return Result.json(json); + } catch (UnsupportedEncodingException e) { + this.stream = null; + return null; + } + } + } + return null; + } + + // Variant A: + // [type:1][msgId:4 LE][totalLen:4 LE][offset:4 LE][payload...] + private Result tryBinaryV1(byte[] value, long nowMs) { + if (value.length < 14) return null; + int type = value[0] & 0xFF; + if (type != LEGACY_TYPE_FEED && type != LEGACY_TYPE_PING) return null; + if (type == LEGACY_TYPE_PING) return Result.ping(); + + int msgId = readLe32(value, 1); + int totalLen = readLe32(value, 5); + int offset = readLe32(value, 9); + int headerLen = 13; + if (totalLen <= 0 || offset < 0 || offset > totalLen) return null; + if (value.length <= headerLen) return null; + + byte[] chunk = new byte[value.length - headerLen]; + System.arraycopy(value, headerLen, chunk, 0, chunk.length); + + synchronized (lock) { + pruneLocked(nowMs); + Assembly assembly = assemblies.get(msgId); + if (assembly == null || assembly.totalLen != totalLen) { + assembly = new Assembly(msgId, totalLen, nowMs); + assemblies.put(msgId, assembly); + } + assembly.lastUpdateMs = nowMs; + assembly.chunksByOffset.put(offset, chunk); + + byte[] complete = assembly.toByteArrayIfComplete(); + if (complete != null) { + assemblies.remove(msgId); + try { + String json = new String(complete, "UTF-8"); + return Result.json(json); + } catch (UnsupportedEncodingException e) { + Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + msgId); + return null; + } + } + } + return null; + } + + // Variant B: + // [type:1][msgId:2 LE][chunkIndex:2 LE][totalChunks:2 LE][payload...] + private Result tryBinaryV2(byte[] value, long nowMs) { + if (value.length < 8) return null; + int type = value[0] & 0xFF; + if (type != LEGACY_TYPE_FEED && type != LEGACY_TYPE_PING) return null; + if (type == LEGACY_TYPE_PING) return Result.ping(); + + int msgId = readLe16(value, 1); + int chunkIndex = readLe16(value, 3); + int totalChunks = readLe16(value, 5); + int headerLen = 7; + + if (totalChunks <= 0 || chunkIndex < 0 || chunkIndex >= totalChunks) return null; + if (value.length <= headerLen) return null; + + byte[] chunk = new byte[value.length - headerLen]; + System.arraycopy(value, headerLen, chunk, 0, chunk.length); + + synchronized (lock) { + pruneLocked(nowMs); + + // Reuse Assembly but interpret totalLen as totalChunks, and offsets as chunkIndex. + Assembly assembly = assemblies.get(msgId); + if (assembly == null || assembly.totalLen != totalChunks) { + assembly = new Assembly(msgId, totalChunks, nowMs); + assemblies.put(msgId, assembly); + } + assembly.lastUpdateMs = nowMs; + assembly.chunksByOffset.put(chunkIndex, chunk); + + if (assembly.chunksByOffset.size() >= totalChunks) { + // Concatenate in chunk order. + int totalBytes = 0; + for (int i = 0; i < totalChunks; i++) { + byte[] c = assembly.chunksByOffset.get(i); + if (c == null) return null; + totalBytes += c.length; + } + byte[] out = new byte[totalBytes]; + int pos = 0; + for (int i = 0; i < totalChunks; i++) { + byte[] c = assembly.chunksByOffset.get(i); + System.arraycopy(c, 0, out, pos, c.length); + pos += c.length; + } + assemblies.remove(msgId); + try { + String json = new String(out, "UTF-8"); + return Result.json(json); + } catch (UnsupportedEncodingException e) { + Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + msgId); + return null; + } + } + } + return null; + } + + private void pruneLocked(long nowMs) { + if (assemblies.isEmpty()) return; + Integer[] keys = assemblies.keySet().toArray(new Integer[assemblies.size()]); + for (int i = 0; i < keys.length; i++) { + Assembly a = assemblies.get(keys[i]); + if (a == null) continue; + if (nowMs - a.lastUpdateMs > ASSEMBLY_TIMEOUT_MS) { + assemblies.remove(keys[i]); + Log.w(Constants.TAG_FEED, "Assembly timeout msgId=" + keys[i]); + } + } + } + + private void pruneChunkAssembliesLocked(long nowMs) { + if (chunkAssemblies.isEmpty()) return; + Integer[] keys = chunkAssemblies.keySet().toArray(new Integer[chunkAssemblies.size()]); + for (int i = 0; i < keys.length; i++) { + ChunkAssembly a = chunkAssemblies.get(keys[i]); + if (a == null) continue; + if ((nowMs - a.lastUpdateMs) > ASSEMBLY_TIMEOUT_MS) { + chunkAssemblies.remove(keys[i]); + Log.w(Constants.TAG_FEED, "Chunk assembly timeout msgId=" + keys[i]); + } + } + } + + private static int readLe16(byte[] b, int off) { + return (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8); + } + + private static int readLe32(byte[] b, int off) { + return (b[off] & 0xFF) + | ((b[off + 1] & 0xFF) << 8) + | ((b[off + 2] & 0xFF) << 16) + | ((b[off + 3] & 0xFF) << 24); + } + + private static int indexOfByte(byte[] b, byte needle) { + for (int i = 0; i < b.length; i++) { + if (b[i] == needle) return i; + } + return -1; + } + + // Returns the end index of the first complete top-level JSON object, or -1. + private static int findJsonObjectEnd(byte[] b) { + int depth = 0; + boolean inString = false; + boolean escape = false; + boolean started = false; + + for (int i = 0; i < b.length; i++) { + int c = b[i] & 0xFF; + if (!started) { + if (c == '{') { + started = true; + depth = 1; + } + continue; + } + + if (inString) { + if (escape) { + escape = false; + } else if (c == '\\') { + escape = true; + } else if (c == '"') { + inString = false; + } + continue; + } + + if (c == '"') { + inString = true; + continue; + } + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) return i; + if (depth < 0) return -1; + } + } + return -1; + } + + private void logParseErrorRateLimited(long nowMs, String reason, byte[] bytes) { + synchronized (lock) { + if (nowMs - lastParseErrorLogAtMs < 1000L) return; + lastParseErrorLogAtMs = nowMs; + } + Log.w(Constants.TAG_FEED, "Frame parse issue: " + reason + " len=" + (bytes != null ? bytes.length : 0)); + } + + private void logChunkProgressRateLimited(long nowMs, long msgId, int chunkIndex, int chunkCount, int payloadLen, int receivedChunks) { + synchronized (lock) { + if (nowMs - lastChunkLogAtMs < 500L) return; + lastChunkLogAtMs = nowMs; + } + Log.d(Constants.TAG_FEED, "Chunk msgId=" + msgId + " idx=" + chunkIndex + "/" + chunkCount + + " payload=" + payloadLen + " received=" + receivedChunks + "/" + chunkCount); + } + + private Result jsonResultOrNull(long nowMs, int msgId, byte[] out, String mode) { + if (out == null) return null; + try { + String json = new String(out, "UTF-8"); + if (json.length() == 0 || json.charAt(0) != '{') { + logParseErrorRateLimited(nowMs, "reassembled payload not JSON object msgId=" + u32(msgId) + " mode=" + mode, out); + } + Log.i(Constants.TAG_FEED, "Complete FULL_FEED msgId=" + u32(msgId) + " bytes=" + out.length + " mode=" + mode); + return Result.json(json); + } catch (UnsupportedEncodingException e) { + Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + u32(msgId) + " mode=" + mode); + return null; + } + } + + private static boolean looksLikeFeedTxFrame(byte[] value) { + if (value == null || value.length < FRAME_HEADER_LEN) return false; + int msgType = value[4] & 0xFF; + if (msgType != MSGTYPE_FULL_FEED && msgType != MSGTYPE_PING) return false; + int chunkCount = readLe16(value, 7); + int chunkIndex = readLe16(value, 5); + if (chunkCount <= 0 || chunkCount > MAX_CHUNKS) return false; + return chunkIndex >= 0 && chunkIndex < chunkCount; + } + + private static long u32(int v) { + return v & 0xFFFFFFFFL; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/HudService.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/HudService.java new file mode 100644 index 0000000..5034aa4 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/HudService.java @@ -0,0 +1,350 @@ +package sh.nym.irisglass; + +import com.google.android.glass.media.Sounds; +import com.google.android.glass.timeline.LiveCard; +import com.google.android.glass.timeline.LiveCard.PublishMode; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.os.IBinder; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import android.text.format.DateFormat; +import android.util.Log; +import android.view.View; +import android.view.SoundEffectConstants; +import android.widget.RemoteViews; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; + +public final class HudService extends Service { + private static final String LIVE_CARD_TAG = "iris_now"; + + private static final int COLOR_GRAY = 0xFF808080; + private static final int COLOR_BLUE = 0xFF34A7FF; + private static final int COLOR_RED = 0xFFCC3333; + private static final int COLOR_GREEN = 0xFF99CC33; + private static final int COLOR_YELLOW = 0xFFDDBB11; + + private LiveCard liveCard; + private String lastRenderedKey; + private String bleStatus = "Idle"; + private String bleError = null; + private boolean pulseOn = false; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private final Runnable clockTick = new Runnable() { + @Override + public void run() { + render(HudState.get().snapshot()); + scheduleNextClockTick(); + } + }; + + private final Runnable pulseTick = new Runnable() { + @Override + public void run() { + if (!isScanning(bleStatus) || liveCard == null) return; + pulseOn = !pulseOn; + render(HudState.get().snapshot()); + mainHandler.postDelayed(this, 500L); + } + }; + + private final HudState.Listener hudListener = new HudState.Listener() { + @Override + public void onHudStateChanged(HudState.Snapshot snapshot, boolean shouldRender) { + if (shouldRender) { + render(snapshot); + } + } + }; + + private final BroadcastReceiver bleStatusReceiver = new BroadcastReceiver() { + @Override + public void onReceive(android.content.Context context, Intent intent) { + if (intent == null) return; + if (!Constants.ACTION_BLE_STATUS.equals(intent.getAction())) return; + bleStatus = intent.getStringExtra(Constants.EXTRA_BLE_STATUS); + bleError = intent.getStringExtra(Constants.EXTRA_BLE_ERROR); + updatePulse(); + render(HudState.get().snapshot()); + } + }; + + @Override + public void onCreate() { + super.onCreate(); + publishLiveCard(); + HudState.get().addListener(hudListener); + registerReceiver(bleStatusReceiver, new IntentFilter(Constants.ACTION_BLE_STATUS)); + updatePulse(); + render(HudState.get().snapshot()); + scheduleNextClockTick(); + startService(new Intent(this, BleLinkService.class)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + String action = intent.getAction(); + if (Constants.ACTION_RESCAN.equals(action)) { + startService(new Intent(this, BleLinkService.class).setAction(Constants.ACTION_RESCAN)); + } else if (Constants.ACTION_WINNER_CHANGED.equals(action)) { + handleWinnerChanged(intent); + } + } + return START_STICKY; + } + + @Override + public void onDestroy() { + try { + HudState.get().removeListener(hudListener); + } catch (Throwable ignored) { + } + try { + unregisterReceiver(bleStatusReceiver); + } catch (Throwable ignored) { + } + mainHandler.removeCallbacks(clockTick); + mainHandler.removeCallbacks(pulseTick); + unpublishLiveCard(); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void publishLiveCard() { + try { + if (liveCard != null) return; + + // Tap opens the swipeable feed UI. + PendingIntent pi = PendingIntent.getActivity( + this, + 0, + new Intent(this, FeedActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_UPDATE_CURRENT + ); + liveCard = new LiveCard(this, LIVE_CARD_TAG); + liveCard.setAction(pi); + liveCard.attach(this); + liveCard.publish(PublishMode.REVEAL); + Log.i(Constants.TAG_HUD, "LiveCard published"); + } catch (Throwable t) { + Log.e(Constants.TAG_HUD, "Failed to publish LiveCard", t); + stopSelf(); + } + } + + private void unpublishLiveCard() { + if (liveCard == null) return; + try { + liveCard.unpublish(); + Log.i(Constants.TAG_HUD, "LiveCard unpublished"); + } catch (Throwable t) { + Log.w(Constants.TAG_HUD, "Failed to unpublish LiveCard: " + t); + } finally { + liveCard = null; + lastRenderedKey = null; + } + } + + private void render(HudState.Snapshot snap) { + LiveCard lc = liveCard; + if (lc == null || snap == null) return; + + FeedEnvelope env = new FeedEnvelope(1, 0L, snap.items, snap.meta); + java.util.List active = env.activeItems(); + boolean hasFeed = !active.isEmpty(); + + FeedModel model = hasFeed ? FeedModelBuilder.fromEnvelope(env) : null; + String title = (model != null && hasFeed) ? safe(model.winnerTitle) : ""; + String subtitle = (model != null && hasFeed) ? safe(model.winnerSubtitle) : ""; + int more = (model != null && hasFeed) ? model.moreCount : 0; + int dotColor = computeDotColor(bleStatus, bleError); + String dot = "\u25CF"; + + String timeText = formatNowTime(); + String dayText = formatNowDayOfWeek(); + String dateText = formatNowMonthDay(); + WeatherInfo weather = WeatherInfoParser.fromEnvelope(env); + String weatherTemp = weather != null ? safe(weather.temperature) : ""; + String weatherIconHint = weather != null ? safe(weather.iconText) : ""; + + String key = title + "\n" + subtitle + "\n" + more + "\n" + dotColor + "\n" + timeText + "\n" + dayText + "\n" + dateText + "\n" + weatherIconHint + "\n" + weatherTemp; + if (key.equals(lastRenderedKey)) return; + lastRenderedKey = key; + + RemoteViews rv = new RemoteViews(getPackageName(), R.layout.hud_live_card); + rv.setTextViewText(R.id.hud_title, title); + rv.setTextViewText(R.id.hud_subtitle, subtitle); + + if (!hasFeed) { + rv.setViewVisibility(R.id.hud_right_col, View.GONE); + rv.setViewVisibility(R.id.hud_more_badge, View.GONE); + } else { + rv.setViewVisibility(R.id.hud_right_col, View.VISIBLE); + if (more > 0) { + rv.setViewVisibility(R.id.hud_more_badge, View.VISIBLE); + rv.setTextViewText(R.id.hud_more_badge, "+" + more); + } else { + rv.setViewVisibility(R.id.hud_more_badge, View.GONE); + } + } + + rv.setTextViewText(R.id.hud_status_dot, dot); + rv.setTextColor(R.id.hud_status_dot, dotColor); + rv.setTextViewText(R.id.hud_time, timeText); + rv.setTextViewText(R.id.hud_day, dayText); + rv.setTextViewText(R.id.hud_date, dateText); + if (weatherTemp.length() > 0) { + rv.setViewVisibility(R.id.hud_weather_row, View.VISIBLE); + int iconRes = WeatherV2Icons.resolveResId(weatherIconHint, isNightNow()); + rv.setImageViewResource(R.id.hud_weather_icon, iconRes); + rv.setTextViewText(R.id.hud_weather_temp, weatherTemp); + } else { + rv.setViewVisibility(R.id.hud_weather_row, View.GONE); + } + + try { + lc.setViews(rv); + } catch (Throwable t) { + Log.w(Constants.TAG_HUD, "LiveCard setViews failed: " + t); + } + } + + private void handleWinnerChanged(Intent intent) { + String winnerId = intent != null ? intent.getStringExtra(Constants.EXTRA_WINNER_ID) : null; + Log.i(Constants.TAG_HUD, "Winner changed: nudge LiveCard winnerId=" + winnerId); + + publishLiveCard(); + render(HudState.get().snapshot()); + + tryWakeScreen(); + tryPlayNudgeSound(); + + try { + if (liveCard != null) liveCard.navigate(); + } catch (Throwable t) { + Log.w(Constants.TAG_HUD, "LiveCard navigate failed: " + t); + } + } + + private void tryWakeScreen() { + try { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm == null) return; + PowerManager.WakeLock wl = pm.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK + | PowerManager.ACQUIRE_CAUSES_WAKEUP + | PowerManager.ON_AFTER_RELEASE, + "IrisGlass:WinnerChanged" + ); + wl.acquire(3000L); + } catch (Throwable t) { + Log.w(Constants.TAG_HUD, "Wake screen failed: " + t); + } + } + + private void tryPlayNudgeSound() { + AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE); + if (am == null) return; + try { + am.playSoundEffect(Sounds.SUCCESS); + } catch (Throwable ignored) { + try { + am.playSoundEffect(SoundEffectConstants.CLICK); + } catch (Throwable t) { + Log.w(Constants.TAG_HUD, "Play sound failed: " + t); + } + } + } + + private void updatePulse() { + mainHandler.removeCallbacks(pulseTick); + pulseOn = false; + if (isScanning(bleStatus)) { + mainHandler.postDelayed(pulseTick, 500L); + } + } + + private boolean isScanning(String statusOrNull) { + if (statusOrNull == null) return false; + String s = statusOrNull.toLowerCase(java.util.Locale.US); + return s.contains("scan"); + } + + private int computeDotColor(String statusOrNull, String errorOrNull) { + String status = statusOrNull != null ? statusOrNull : ""; + String err = errorOrNull != null ? errorOrNull.trim() : ""; + if (err.length() > 0) return COLOR_RED; + + String s = status.toLowerCase(java.util.Locale.US); + if (s.contains("connected")) return COLOR_GREEN; + if (s.contains("disconnected") || s.contains("stopped") || s.contains("bluetooth off")) return COLOR_RED; + if (isScanning(status)) return pulseOn ? COLOR_BLUE : COLOR_GRAY; + if (s.contains("connect") || s.contains("discover") || s.contains("subscrib")) return COLOR_BLUE; + return COLOR_GRAY; + } + + private void scheduleNextClockTick() { + mainHandler.removeCallbacks(clockTick); + long now = System.currentTimeMillis(); + long delay = 60_000L - (now % 60_000L) + 50L; + if (delay < 250L) delay = 250L; + mainHandler.postDelayed(clockTick, delay); + } + + private String formatNowTime() { + try { + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date()); + } catch (Throwable t) { + return ""; + } + } + + private String formatNowDayOfWeek() { + try { + String dow = new SimpleDateFormat("EEEE", Locale.getDefault()).format(new Date()); + return dow != null ? dow.trim() : ""; + } catch (Throwable t) { + return ""; + } + } + + private String formatNowMonthDay() { + try { + // Use abbreviated month names (Jan, Feb, ...), localized. + String md = new SimpleDateFormat("MMM d", Locale.getDefault()).format(new Date()); + return md != null ? md.trim() : ""; + } catch (Throwable t) { + return ""; + } + } + + private boolean isNightNow() { + try { + int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); + return hour < 6 || hour >= 18; + } catch (Throwable t) { + return false; + } + } + + private static String safe(String s) { + return s != null ? s.trim() : ""; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/HudState.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/HudState.java new file mode 100644 index 0000000..502e185 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/HudState.java @@ -0,0 +1,87 @@ +package sh.nym.irisglass; + +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class HudState { + public static final class Snapshot { + public final List items; + public final FeedMeta meta; + + private Snapshot( + List items, + FeedMeta meta + ) { + this.items = items != null ? items : Collections.emptyList(); + this.meta = meta; + } + } + + public interface Listener { + void onHudStateChanged(Snapshot snapshot, boolean shouldRender); + } + + private static final HudState INSTANCE = new HudState(); + + public static HudState get() { + return INSTANCE; + } + + private final Object lock = new Object(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final List listeners = new ArrayList(); + + private List items = Collections.emptyList(); + private FeedMeta meta = null; + + private HudState() {} + + public Snapshot snapshot() { + synchronized (lock) { + return new Snapshot(items, meta); + } + } + + public void addListener(Listener listener) { + if (listener == null) return; + synchronized (lock) { + listeners.add(listener); + } + } + + public void removeListener(Listener listener) { + if (listener == null) return; + synchronized (lock) { + listeners.remove(listener); + } + } + + public void setFeed(List items, FeedMeta meta, boolean shouldRender) { + List listenerCopy; + Snapshot snapshot; + synchronized (lock) { + ArrayList copy = new ArrayList(); + if (items != null) copy.addAll(items); + this.items = Collections.unmodifiableList(copy); + this.meta = meta; + snapshot = new Snapshot(this.items, this.meta); + listenerCopy = new ArrayList(listeners); + } + notifyListeners(listenerCopy, snapshot, shouldRender); + } + + private void notifyListeners(final List listenerCopy, final Snapshot snapshot, final boolean shouldRender) { + mainHandler.post(new Runnable() { + @Override + public void run() { + for (int i = 0; i < listenerCopy.size(); i++) { + listenerCopy.get(i).onHudStateChanged(snapshot, shouldRender); + } + } + }); + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/MainActivity.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/MainActivity.java new file mode 100644 index 0000000..3df7512 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/MainActivity.java @@ -0,0 +1,15 @@ +package sh.nym.irisglass; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +public final class MainActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + startService(new Intent(this, HudService.class).setAction(Constants.ACTION_START_HUD)); + finish(); + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/MenuActivity.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/MenuActivity.java new file mode 100644 index 0000000..e4b1409 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/MenuActivity.java @@ -0,0 +1,183 @@ +package sh.nym.irisglass; + +import com.google.android.glass.timeline.LiveCard; +import com.google.android.glass.view.WindowUtils; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +import java.util.HashSet; + +/** + * Manages the Glass options menu overlay (icon + label) similar to the GDK timer sample. + * This Activity immediately opens the options menu and finishes when the menu closes. + */ +public final class MenuActivity extends Activity { + public static final String EXTRA_CARD_ID = "card_id"; + public static final String EXTRA_CARD_TITLE = "card_title"; + public static final String EXTRA_ACTIONS = "actions"; + public static final String EXTRA_ACTION = "action"; + + private final Handler handler = new Handler(); + + private boolean attachedToWindow; + private boolean isMenuClosed; + private boolean preparePanelCalled; + private boolean fromLiveCardVoice; + + private String cardId; + private HashSet allowedActions = new HashSet(); + + public static Intent newIntent(Context context, FeedItem item) { + Intent i = new Intent(context, MenuActivity.class); + if (item != null) { + i.putExtra(EXTRA_CARD_ID, item.id); + i.putExtra(EXTRA_CARD_TITLE, item.title); + if (item.actions != null) { + i.putExtra(EXTRA_ACTIONS, item.actions.toArray(new String[item.actions.size()])); + } + } + return i; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Increase contrast so menu labels are readable over underlying cards. + try { + WindowManager.LayoutParams lp = getWindow().getAttributes(); + lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND; + lp.dimAmount = 0.65f; + getWindow().setAttributes(lp); + } catch (Throwable ignored) { + } + + fromLiveCardVoice = getIntent().getBooleanExtra(LiveCard.EXTRA_FROM_LIVECARD_VOICE, false); + if (fromLiveCardVoice) { + getWindow().requestFeature(WindowUtils.FEATURE_VOICE_COMMANDS); + } + + cardId = getIntent().getStringExtra(EXTRA_CARD_ID); + String[] actions = getIntent().getStringArrayExtra(EXTRA_ACTIONS); + if (actions != null) { + for (int i = 0; i < actions.length; i++) { + if (actions[i] != null) allowedActions.add(actions[i]); + } + } + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + attachedToWindow = true; + openMenu(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + attachedToWindow = false; + } + + @Override + public boolean onCreatePanelMenu(int featureId, Menu menu) { + if (isMyMenu(featureId)) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.feed_actions, menu); + return true; + } + return super.onCreatePanelMenu(featureId, menu); + } + + @Override + public boolean onPreparePanel(int featureId, View view, Menu menu) { + preparePanelCalled = true; + if (isMyMenu(featureId)) { + // Enable only actions present on the tapped card. + setOptionsMenuState(menu.findItem(R.id.action_dismiss), allowedActions.contains("DISMISS")); + setOptionsMenuState(menu.findItem(R.id.action_snooze_2h), allowedActions.contains("SNOOZE_2H")); + setOptionsMenuState(menu.findItem(R.id.action_snooze_24h), allowedActions.contains("SNOOZE_24H")); + setOptionsMenuState(menu.findItem(R.id.action_save), allowedActions.contains("SAVE")); + // Always allow Back. + setOptionsMenuState(menu.findItem(R.id.action_back), true); + return !isMenuClosed; + } + return super.onPreparePanel(featureId, view, menu); + } + + @Override + public boolean onMenuItemSelected(int featureId, final MenuItem item) { + if (!isMyMenu(featureId)) return super.onMenuItemSelected(featureId, item); + if (item == null) return true; + + final String action = actionForItemId(item.getItemId()); + if (action == null) return true; + + if ("BACK".equals(action)) { + finish(); + return true; + } + + // Post for proper options menu animation (per timer sample guidance). + handler.post(new Runnable() { + @Override + public void run() { + Intent r = new Intent(); + r.putExtra(EXTRA_CARD_ID, cardId); + r.putExtra(EXTRA_ACTION, action); + setResult(RESULT_OK, r); + finish(); + } + }); + return true; + } + + @Override + public void onPanelClosed(int featureId, Menu menu) { + super.onPanelClosed(featureId, menu); + if (isMyMenu(featureId)) { + isMenuClosed = true; + finish(); + } + } + + private void openMenu() { + if (!attachedToWindow) return; + if (fromLiveCardVoice) { + if (preparePanelCalled) { + getWindow().invalidatePanelMenu(WindowUtils.FEATURE_VOICE_COMMANDS); + } + } else { + openOptionsMenu(); + } + } + + private boolean isMyMenu(int featureId) { + return featureId == Window.FEATURE_OPTIONS_PANEL || featureId == WindowUtils.FEATURE_VOICE_COMMANDS; + } + + private static void setOptionsMenuState(MenuItem menuItem, boolean enabled) { + if (menuItem == null) return; + menuItem.setVisible(enabled); + menuItem.setEnabled(enabled); + } + + private static String actionForItemId(int id) { + if (id == R.id.action_dismiss) return "DISMISS"; + if (id == R.id.action_snooze_2h) return "SNOOZE_2H"; + if (id == R.id.action_snooze_24h) return "SNOOZE_24H"; + if (id == R.id.action_save) return "SAVE"; + if (id == R.id.action_back) return "BACK"; + return null; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/SettingsActivity.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/SettingsActivity.java new file mode 100644 index 0000000..bea0332 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/SettingsActivity.java @@ -0,0 +1,137 @@ +package sh.nym.irisglass; + +import android.app.Activity; +import android.bluetooth.BluetoothAdapter; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +public final class SettingsActivity extends Activity { + private final Handler handler = new Handler(); + private TextView text; + + private final Runnable refreshRunnable = new Runnable() { + @Override + public void run() { + refresh(); + handler.postDelayed(this, 1000L); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + startService(new Intent(this, HudService.class).setAction(Constants.ACTION_START_HUD)); + + ScrollView scrollView = new ScrollView(this); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(32, 32, 32, 32); + scrollView.addView(root); + + text = new TextView(this); + text.setTextSize(18f); + text.setTextColor(0xFFFFFFFF); + text.setTypeface(android.graphics.Typeface.MONOSPACE); + root.addView(text, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + Button rescan = new Button(this); + rescan.setText("Rescan"); + rescan.setAllCaps(false); + rescan.setGravity(Gravity.CENTER); + rescan.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + startService(new Intent(SettingsActivity.this, BleLinkService.class).setAction(Constants.ACTION_RESCAN)); + } + }); + root.addView(rescan, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + Button openFeed = new Button(this); + openFeed.setText("Open feed"); + openFeed.setAllCaps(false); + openFeed.setGravity(Gravity.CENTER); + openFeed.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + startActivity(new Intent(SettingsActivity.this, FeedActivity.class)); + } + }); + root.addView(openFeed, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + scrollView.setBackgroundColor(0xFF000000); + setContentView(scrollView); + } + + @Override + protected void onResume() { + super.onResume(); + handler.removeCallbacks(refreshRunnable); + handler.post(refreshRunnable); + } + + @Override + protected void onPause() { + handler.removeCallbacks(refreshRunnable); + super.onPause(); + } + + private void refresh() { + HudState.Snapshot s = HudState.get().snapshot(); + + StringBuilder sb = new StringBuilder(); + sb.append("BLE state: "); + BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter(); + sb.append(a != null && a.isEnabled() ? "ON" : "OFF"); + sb.append("\n"); + + int total = s.items != null ? s.items.size() : 0; + int active = 0; + if (s.items != null) { + for (int i = 0; i < s.items.size(); i++) { + FeedItem it = s.items.get(i); + if (it != null && it.ttlSec > 0) active++; + } + } + sb.append("Cards: ").append(total).append(" (active ").append(active).append(")\n"); + if (s.meta != null) { + sb.append("Winner id: ").append(nz(s.meta.winnerId)).append("\n"); + sb.append("Unread: ").append(s.meta.unreadCount).append("\n"); + } else { + sb.append("Meta: -\n"); + } + + FeedEnvelope env = new FeedEnvelope(1, 0L, s.items, s.meta); + FeedModel m = FeedModelBuilder.fromEnvelope(env); + if (m != null) { + sb.append("\nWinner:\n"); + sb.append(" ").append(nz(m.winnerTitle)).append("\n"); + sb.append(" ").append(nz(m.winnerSubtitle)).append("\n"); + sb.append("More: +").append(m.moreCount).append("\n"); + } + + text.setText(sb.toString()); + } + + private static String nz(String s) { + return s != null ? s : "-"; + } + +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/SuppressionStore.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/SuppressionStore.java new file mode 100644 index 0000000..948847d --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/SuppressionStore.java @@ -0,0 +1,32 @@ +package sh.nym.irisglass; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +public final class SuppressionStore { + private static final String KEY_PREFIX = "suppression_until_"; + + private final SharedPreferences prefs; + + public SuppressionStore(Context context) { + this.prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + } + + public boolean isSuppressed(String id, long nowEpochSeconds) { + if (id == null) return false; + long until = prefs.getLong(KEY_PREFIX + id, 0L); + if (until <= 0L) return false; + if (until <= nowEpochSeconds) { + prefs.edit().remove(KEY_PREFIX + id).apply(); + return false; + } + return true; + } + + public void setSuppressed(String id, long untilEpochSeconds) { + if (id == null) return; + prefs.edit().putLong(KEY_PREFIX + id, untilEpochSeconds).apply(); + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherCondition.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherCondition.java new file mode 100644 index 0000000..c34ef49 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherCondition.java @@ -0,0 +1,80 @@ +package sh.nym.irisglass; + +public final class WeatherCondition { + private WeatherCondition() {} + + public static final String BLIZZARD = "BLIZZARD"; + public static final String BLOWING_DUST = "BLOWING_DUST"; + public static final String BLOWING_SNOW = "BLOWING_SNOW"; + public static final String BREEZY = "BREEZY"; + public static final String CLEAR = "CLEAR"; + public static final String CLOUDY = "CLOUDY"; + public static final String DRIZZLE = "DRIZZLE"; + public static final String FLURRIES = "FLURRIES"; + public static final String FOGGY = "FOGGY"; + public static final String FREEZING_DRIZZLE = "FREEZING_DRIZZLE"; + public static final String FREEZING_RAIN = "FREEZING_RAIN"; + public static final String FRIGID = "FRIGID"; + public static final String HAIL = "HAIL"; + public static final String HAZE = "HAZE"; + public static final String HEAVY_RAIN = "HEAVY_RAIN"; + public static final String HEAVY_SNOW = "HEAVY_SNOW"; + public static final String HOT = "HOT"; + public static final String HURRICANE = "HURRICANE"; + public static final String ISOLATED_THUNDERSTORMS = "ISOLATED_THUNDERSTORMS"; + public static final String MOSTLY_CLEAR = "MOSTLY_CLEAR"; + public static final String MOSTLY_CLOUDY = "MOSTLY_CLOUDY"; + public static final String PARTLY_CLOUDY = "PARTLY_CLOUDY"; + public static final String RAIN = "RAIN"; + public static final String SCATTERED_THUNDERSTORMS = "SCATTERED_THUNDERSTORMS"; + public static final String SLEET = "SLEET"; + public static final String SMOKY = "SMOKY"; + public static final String SNOW = "SNOW"; + public static final String STRONG_STORMS = "STRONG_STORMS"; + public static final String SUN_FLURRIES = "SUN_FLURRIES"; + public static final String SUN_SHOWERS = "SUN_SHOWERS"; + public static final String THUNDERSTORMS = "THUNDERSTORMS"; + public static final String TROPICAL_STORM = "TROPICAL_STORM"; + public static final String WINDY = "WINDY"; + public static final String WINTRY_MIX = "WINTRY_MIX"; + public static final String UNKNOWN = "UNKNOWN"; + + public static final String[] ALL = new String[] { + BLIZZARD, + BLOWING_DUST, + BLOWING_SNOW, + BREEZY, + CLEAR, + CLOUDY, + DRIZZLE, + FLURRIES, + FOGGY, + FREEZING_DRIZZLE, + FREEZING_RAIN, + FRIGID, + HAIL, + HAZE, + HEAVY_RAIN, + HEAVY_SNOW, + HOT, + HURRICANE, + ISOLATED_THUNDERSTORMS, + MOSTLY_CLEAR, + MOSTLY_CLOUDY, + PARTLY_CLOUDY, + RAIN, + SCATTERED_THUNDERSTORMS, + SLEET, + SMOKY, + SNOW, + STRONG_STORMS, + SUN_FLURRIES, + SUN_SHOWERS, + THUNDERSTORMS, + TROPICAL_STORM, + WINDY, + WINTRY_MIX, + UNKNOWN + }; +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfo.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfo.java new file mode 100644 index 0000000..7b421c3 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfo.java @@ -0,0 +1,30 @@ +package sh.nym.irisglass; + +public final class WeatherInfo { + public final String id; + public final String location; + public final String temperature; + public final String condition; + public final String hiLo; + public final String iconText; + public final long generatedAtEpochSeconds; + + public WeatherInfo( + String id, + String location, + String temperature, + String condition, + String hiLo, + String iconText, + long generatedAtEpochSeconds + ) { + this.id = id; + this.location = location; + this.temperature = temperature; + this.condition = condition; + this.hiLo = hiLo; + this.iconText = iconText; + this.generatedAtEpochSeconds = generatedAtEpochSeconds; + } +} + diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfoParser.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfoParser.java new file mode 100644 index 0000000..ffa24fb --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfoParser.java @@ -0,0 +1,269 @@ +package sh.nym.irisglass; + +import org.json.JSONObject; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class WeatherInfoParser { + private WeatherInfoParser() {} + + public static WeatherInfo fromEnvelope(FeedEnvelope env) { + if (env == null) return null; + + JSONObject bestCard = null; + double bestPriority = -1e9; + for (int i = 0; i < env.items.size(); i++) { + FeedItem it = env.items.get(i); + if (it == null) continue; + if (it.ttlSec <= 0) continue; + if (it.bucket == null || !BucketType.FYI.equalsIgnoreCase(it.bucket)) continue; + + boolean looksLikeWeather = false; + if (it.type != null) { + String t = it.type.toUpperCase(Locale.US); + looksLikeWeather = t.contains("WEATHER"); + } + + JSONObject raw = it.raw; + if (raw == null) continue; + if (!looksLikeWeather && raw.optJSONObject("weather") == null) continue; + + double p = it.priority; + if (bestCard == null || p > bestPriority) { + bestCard = raw; + bestPriority = p; + } + } + if (bestCard == null) return null; + + JSONObject w = bestCard.optJSONObject("weather"); + JSONObject src = w != null ? w : bestCard; + + String id = bestCard.optString("id", "weather"); + String rawTitle = bestCard.optString("title", ""); + String rawSubtitle = bestCard.optString("subtitle", ""); + + String location = firstNonEmpty( + src.optString("location", null), + src.optString("place", null) + ); + + String temp = firstNonEmpty( + src.optString("temperature", null), + src.optString("temp", null), + formatDegree(src, "temp_f"), + formatDegree(src, "temp_c"), + src.optString("temp_text", null) + ); + + String condition = firstNonEmpty( + src.optString("condition", null), + bestCard.optString("condition", null), + src.optString("summary", null), + src.optString("text", null) + ); + + String hi = firstNonEmpty( + formatDegree(src, "high_f"), + formatDegree(src, "hi_f"), + formatDegree(src, "high_c"), + formatDegree(src, "hi_c"), + src.optString("high", null), + src.optString("hi", null) + ); + String lo = firstNonEmpty( + formatDegree(src, "low_f"), + formatDegree(src, "lo_f"), + formatDegree(src, "low_c"), + formatDegree(src, "lo_c"), + src.optString("low", null), + src.optString("lo", null) + ); + String hiLo = ""; + if (hi != null || lo != null) { + String hiText = hi != null ? ("H " + hi) : ""; + String loText = lo != null ? ("L " + lo) : ""; + if (hiText.length() > 0 && loText.length() > 0) hiLo = hiText + " " + loText; + else if (hiText.length() > 0) hiLo = hiText; + else hiLo = loText; + } + + if (w == null) { + WeatherTitleParse parsedTitle = parseWeatherFromText(rawTitle); + if (parsedTitle != null) { + if (location == null) location = parsedTitle.location; + if (temp == null) temp = parsedTitle.temperature; + if (condition == null) condition = parsedTitle.condition; + } + if (temp == null) { + WeatherTitleParse parsedSubtitle = parseWeatherFromText(rawSubtitle); + if (parsedSubtitle != null) temp = parsedSubtitle.temperature; + } + } + + if ((hiLo == null || hiLo.length() == 0) && rawSubtitle != null) { + String s = rawSubtitle.trim(); + if (s.length() > 0) { + String lower = s.toLowerCase(Locale.US); + if (lower.startsWith("feels")) { + hiLo = s; + } + } + } + + if (temp == null) temp = firstNonEmpty(rawSubtitle, ""); + if (condition == null) { + String s = rawSubtitle != null ? rawSubtitle.trim() : ""; + if (s.length() > 0 && !s.toLowerCase(Locale.US).startsWith("feels")) { + condition = s; + } else { + condition = ""; + } + } + if (location == null) location = nz(rawTitle, "Weather"); + + String iconText = pickWeatherIconToken(firstNonEmpty( + src.optString("icon", null), + bestCard.optString("icon", null), + src.optString("condition", null), + bestCard.optString("condition", null), + condition + )); + + location = truncate(location, 24); + condition = truncate(condition, 28); + hiLo = truncate(hiLo, 28); + + return new WeatherInfo(id, location, temp, condition, hiLo, iconText, env.generatedAtEpochSeconds); + } + + private static final class WeatherTitleParse { + final String location; + final String temperature; + final String condition; + + private WeatherTitleParse(String location, String temperature, String condition) { + this.location = location; + this.temperature = temperature; + this.condition = condition; + } + } + + private static final Pattern TEMP_TOKEN = Pattern.compile("(-?\\d+(?:\\.\\d+)?)\\s*\\u00B0\\s*([cCfF])?"); + + private static WeatherTitleParse parseWeatherFromText(String text) { + if (text == null) return null; + String t = text.trim(); + if (t.length() == 0) return null; + + Matcher m = TEMP_TOKEN.matcher(t); + if (!m.find()) return null; + + String number = m.group(1); + String unit = m.group(2); + String temp = number + "\u00B0" + (unit != null ? unit.toUpperCase(Locale.US) : ""); + + String before = t.substring(0, m.start()).trim(); + String after = t.substring(m.end()).trim(); + after = stripLeadingSeparators(after); + + String location = before.length() > 0 ? before : null; + String condition = after.length() > 0 ? after : null; + + return new WeatherTitleParse(location, temp, condition); + } + + private static String stripLeadingSeparators(String s) { + if (s == null) return ""; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '-' || c == ':' || c == ',' || c == '\u00B7') { + i++; + continue; + } + if (Character.isWhitespace(c)) { + i++; + continue; + } + break; + } + return s.substring(i).trim(); + } + + private static String truncate(String s, int maxChars) { + if (s == null) return ""; + if (s.length() <= maxChars) return s; + if (maxChars <= 1) return s.substring(0, maxChars); + return s.substring(0, maxChars - 1) + "\u2026"; + } + + private static String nz(String s, String fallback) { + return (s == null || s.length() == 0) ? fallback : s; + } + + private static String firstNonEmpty(String... values) { + if (values == null) return null; + for (int i = 0; i < values.length; i++) { + String v = values[i]; + if (v != null && v.length() > 0) return v; + } + return null; + } + + private static String formatDegree(JSONObject src, String key) { + if (src == null || key == null) return null; + if (!src.has(key)) return null; + double v = src.optDouble(key, Double.NaN); + if (Double.isNaN(v)) return null; + int rounded = (int) Math.round(v); + return rounded + "\u00B0"; + } + + private static String pickWeatherIconToken(String hint) { + if (hint == null) return "sunny"; + String h = hint.toLowerCase(Locale.US).trim(); + if (h.length() == 0) return "sunny"; + if (h.endsWith(".png")) h = h.substring(0, h.length() - 4); + h = h.replace(' ', '_').replace('-', '_'); + + // If the hint already looks like a v2 icon name, keep it. + if (h.startsWith("mostly_") + || h.startsWith("partly_") + || h.startsWith("scattered_") + || h.startsWith("isolated_") + || h.contains("tstorms") + || h.contains("wintry_mix") + || h.contains("haze_fog")) { + return h; + } + + if (h.contains("tornado")) return "tornado"; + if (h.contains("blizzard")) return "blizzard"; + if (h.contains("blowing") && h.contains("snow")) return "blowing_snow"; + if (h.contains("thunder") || h.contains("tstorm") || h.contains("storm") || h.contains("lightning")) return "strong_tstorms"; + + if (h.contains("hail") || h.contains("sleet") || h.contains("ice_pellet") || h.contains("freezing_rain")) return "sleet_hail"; + if (h.contains("freezing_drizzle")) return "drizzle"; + if ((h.contains("rain") && h.contains("snow")) || h.contains("wintry_mix")) return "wintry_mix_rain_snow"; + + if (h.contains("heavy") && h.contains("snow")) return "heavy_snow"; + if (h.contains("flurr")) return "flurries"; + if (h.contains("snow")) return "snow_showers_snow"; + + if (h.contains("heavy") && h.contains("rain")) return "heavy_rain"; + if (h.contains("drizzle") || h.contains("sprinkle")) return "drizzle"; + if (h.contains("shower") || h.contains("rain")) return "showers_rain"; + + if (h.contains("haze") || h.contains("fog") || h.contains("mist") || h.contains("smoke") || h.contains("dust")) return "haze_fog_dust_smoke"; + if (h.contains("breezy") || h.contains("windy") || h.equals("wind")) return "mostly_sunny"; + if (h.contains("mostly") && h.contains("cloud")) return "mostly_cloudy"; + if (h.contains("partly") && h.contains("cloud")) return "partly_cloudy"; + if (h.contains("cloud") || h.contains("overcast")) return "cloudy"; + + if (h.contains("night")) return "clear_night"; + return "sunny"; + } +} diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherV2Icons.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherV2Icons.java new file mode 100644 index 0000000..2fbed44 --- /dev/null +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherV2Icons.java @@ -0,0 +1,143 @@ +package sh.nym.irisglass; + +import java.util.Locale; + +public final class WeatherV2Icons { + private WeatherV2Icons() {} + + public static int resolveResId(String hintOrToken, boolean isNight) { + String h = normalize(hintOrToken); + if (h.length() == 0) return R.drawable.weather_v2_sunny; + + // If the feed already sends a v2 filename, use it directly. + int direct = resolveByV2Name(h); + if (direct != 0) return direct; + + // Otherwise map fuzzy tokens/phrases to a v2 icon. + if (containsAny(h, "tornado")) return R.drawable.weather_v2_tornado; + if (containsAny(h, "blizzard")) return R.drawable.weather_v2_blizzard; + if (containsAny(h, "blowing_snow")) return R.drawable.weather_v2_blowing_snow; + + if (containsAny(h, "isolated_thunderstorms", "scattered_thunderstorms")) { + return isNight ? R.drawable.weather_v2_isolated_scattered_tstorms_night : R.drawable.weather_v2_isolated_scattered_tstorms_day; + } + if (containsAny(h, "thunder", "tstorm", "storm", "lightning")) return R.drawable.weather_v2_strong_tstorms; + + if (containsAny(h, "sleet", "hail", "ice_pellet", "icepellet", "freezing_rain")) { + return R.drawable.weather_v2_sleet_hail; + } + if (containsAny(h, "wintry_mix", "mix_rain_snow", "rain_snow", "sleet_snow")) { + return R.drawable.weather_v2_wintry_mix_rain_snow; + } + + if (containsAny(h, "heavy_snow")) return R.drawable.weather_v2_heavy_snow; + if (containsAny(h, "snow", "flurry")) return R.drawable.weather_v2_snow_showers_snow; + + if (containsAny(h, "heavy_rain", "downpour", "pouring")) return R.drawable.weather_v2_heavy_rain; + if (containsAny(h, "drizzle", "sprinkle")) return R.drawable.weather_v2_drizzle; + if (containsAny(h, "shower", "rain")) return R.drawable.weather_v2_showers_rain; + + if (containsAny(h, "haze", "fog", "mist", "smoke", "dust")) return R.drawable.weather_v2_haze_fog_dust_smoke; + + if (containsAny(h, "sun_showers")) return R.drawable.weather_v2_scattered_showers_day; + if (containsAny(h, "sun_flurries")) return R.drawable.weather_v2_flurries; + if (containsAny(h, "breezy", "windy", "wind")) return R.drawable.weather_v2_mostly_sunny; + if (containsAny(h, "hurricane", "tropical_storm")) return R.drawable.weather_v2_strong_tstorms; + if (containsAny(h, "hot", "frigid")) return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny; + + if (containsAny(h, "mostly_cloudy")) { + return isNight ? R.drawable.weather_v2_mostly_cloudy_night : R.drawable.weather_v2_mostly_cloudy_day; + } + if (containsAny(h, "partly_cloudy", "partly")) { + return isNight ? R.drawable.weather_v2_partly_cloudy_night : R.drawable.weather_v2_partly_cloudy; + } + if (containsAny(h, "mostly_sunny")) return R.drawable.weather_v2_mostly_sunny; + if (containsAny(h, "cloud", "overcast")) return R.drawable.weather_v2_cloudy; + + if (containsAny(h, "clear_night", "night")) return R.drawable.weather_v2_clear_night; + if (containsAny(h, "mostly_clear")) { + return isNight ? R.drawable.weather_v2_mostly_clear_night : R.drawable.weather_v2_mostly_sunny; + } + if (containsAny(h, "clear", "sun", "fair")) return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny; + + return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny; + } + + private static int resolveByV2Name(String v2Name) { + switch (v2Name) { + case "blizzard": + return R.drawable.weather_v2_blizzard; + case "blowing_snow": + return R.drawable.weather_v2_blowing_snow; + case "clear_night": + return R.drawable.weather_v2_clear_night; + case "cloudy": + return R.drawable.weather_v2_cloudy; + case "drizzle": + return R.drawable.weather_v2_drizzle; + case "flurries": + return R.drawable.weather_v2_flurries; + case "haze_fog_dust_smoke": + return R.drawable.weather_v2_haze_fog_dust_smoke; + case "heavy_rain": + return R.drawable.weather_v2_heavy_rain; + case "heavy_snow": + return R.drawable.weather_v2_heavy_snow; + case "isolated_scattered_tstorms_day": + return R.drawable.weather_v2_isolated_scattered_tstorms_day; + case "isolated_scattered_tstorms_night": + return R.drawable.weather_v2_isolated_scattered_tstorms_night; + case "mostly_clear_night": + return R.drawable.weather_v2_mostly_clear_night; + case "mostly_cloudy_day": + return R.drawable.weather_v2_mostly_cloudy_day; + case "mostly_cloudy_night": + return R.drawable.weather_v2_mostly_cloudy_night; + case "mostly_sunny": + return R.drawable.weather_v2_mostly_sunny; + case "partly_cloudy": + return R.drawable.weather_v2_partly_cloudy; + case "partly_cloudy_night": + return R.drawable.weather_v2_partly_cloudy_night; + case "scattered_showers_day": + return R.drawable.weather_v2_scattered_showers_day; + case "scattered_showers_night": + return R.drawable.weather_v2_scattered_showers_night; + case "showers_rain": + return R.drawable.weather_v2_showers_rain; + case "sleet_hail": + return R.drawable.weather_v2_sleet_hail; + case "snow_showers_snow": + return R.drawable.weather_v2_snow_showers_snow; + case "strong_tstorms": + return R.drawable.weather_v2_strong_tstorms; + case "sunny": + return R.drawable.weather_v2_sunny; + case "tornado": + return R.drawable.weather_v2_tornado; + case "wintry_mix_rain_snow": + return R.drawable.weather_v2_wintry_mix_rain_snow; + default: + return 0; + } + } + + private static String normalize(String s) { + if (s == null) return ""; + String t = s.trim().toLowerCase(Locale.US); + if (t.endsWith(".png")) t = t.substring(0, t.length() - 4); + t = t.replace(' ', '_'); + t = t.replace('-', '_'); + return t; + } + + private static boolean containsAny(String haystack, String... needles) { + if (haystack == null || haystack.length() == 0 || needles == null) return false; + for (int i = 0; i < needles.length; i++) { + String n = needles[i]; + if (n == null || n.length() == 0) continue; + if (haystack.contains(n)) return true; + } + return false; + } +} diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blizzard.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blizzard.png new file mode 100644 index 0000000..ea77059 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blizzard.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blowing_snow.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blowing_snow.png new file mode 100644 index 0000000..3d8e42c Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blowing_snow.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_clear_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_clear_night.png new file mode 100644 index 0000000..1e1d6e8 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_clear_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_cloudy.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_cloudy.png new file mode 100644 index 0000000..cb8172d Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_cloudy.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_drizzle.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_drizzle.png new file mode 100644 index 0000000..b26ad06 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_drizzle.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_flurries.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_flurries.png new file mode 100644 index 0000000..86a6a55 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_flurries.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_haze_fog_dust_smoke.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_haze_fog_dust_smoke.png new file mode 100644 index 0000000..537c236 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_haze_fog_dust_smoke.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_rain.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_rain.png new file mode 100644 index 0000000..c7f025c Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_rain.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_snow.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_snow.png new file mode 100644 index 0000000..645bcc9 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_snow.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_day.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_day.png new file mode 100644 index 0000000..252723e Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_day.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_night.png new file mode 100644 index 0000000..1e8a341 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_clear_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_clear_night.png new file mode 100644 index 0000000..b35ee13 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_clear_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_day.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_day.png new file mode 100644 index 0000000..fb802a0 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_day.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_night.png new file mode 100644 index 0000000..b85064a Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_sunny.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_sunny.png new file mode 100644 index 0000000..3dc512d Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_sunny.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy.png new file mode 100644 index 0000000..3b4068a Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy_night.png new file mode 100644 index 0000000..62b0b89 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_day.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_day.png new file mode 100644 index 0000000..9e3d5c4 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_day.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_night.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_night.png new file mode 100644 index 0000000..5612b2b Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_night.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_showers_rain.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_showers_rain.png new file mode 100644 index 0000000..2016e35 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_showers_rain.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sleet_hail.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sleet_hail.png new file mode 100644 index 0000000..8a2817e Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sleet_hail.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_snow_showers_snow.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_snow_showers_snow.png new file mode 100644 index 0000000..909cb13 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_snow_showers_snow.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_strong_tstorms.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_strong_tstorms.png new file mode 100644 index 0000000..894d1ea Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_strong_tstorms.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sunny.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sunny.png new file mode 100644 index 0000000..bb9112d Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sunny.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_tornado.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_tornado.png new file mode 100644 index 0000000..829b7a8 Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_tornado.png differ diff --git a/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_wintry_mix_rain_snow.png b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_wintry_mix_rain_snow.png new file mode 100644 index 0000000..fcd0eea Binary files /dev/null and b/IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_wintry_mix_rain_snow.png differ diff --git a/IrisGlass/app/src/main/res/layout/hud_live_card.xml b/IrisGlass/app/src/main/res/layout/hud_live_card.xml new file mode 100644 index 0000000..88f5f92 --- /dev/null +++ b/IrisGlass/app/src/main/res/layout/hud_live_card.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IrisGlass/app/src/main/res/menu/feed_actions.xml b/IrisGlass/app/src/main/res/menu/feed_actions.xml new file mode 100644 index 0000000..e0a768b --- /dev/null +++ b/IrisGlass/app/src/main/res/menu/feed_actions.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/IrisGlass/app/src/main/res/values-night/themes.xml b/IrisGlass/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..8ef4896 --- /dev/null +++ b/IrisGlass/app/src/main/res/values-night/themes.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/IrisGlass/app/src/main/res/values/colors.xml b/IrisGlass/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/IrisGlass/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/IrisGlass/app/src/main/res/values/strings.xml b/IrisGlass/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ea69d3b --- /dev/null +++ b/IrisGlass/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Iris Glass + \ No newline at end of file diff --git a/IrisGlass/app/src/main/res/values/themes.xml b/IrisGlass/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8ef4896 --- /dev/null +++ b/IrisGlass/app/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/IrisGlass/app/src/main/res/xml/backup_rules.xml b/IrisGlass/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/IrisGlass/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/IrisGlass/app/src/main/res/xml/data_extraction_rules.xml b/IrisGlass/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/IrisGlass/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/IrisGlass/app/src/test/java/sh/nym/irisglass/ExampleUnitTest.java b/IrisGlass/app/src/test/java/sh/nym/irisglass/ExampleUnitTest.java new file mode 100644 index 0000000..8289e44 --- /dev/null +++ b/IrisGlass/app/src/test/java/sh/nym/irisglass/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package sh.nym.irisglass; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/IrisGlass/build.gradle b/IrisGlass/build.gradle new file mode 100644 index 0000000..3756278 --- /dev/null +++ b/IrisGlass/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false +} \ No newline at end of file diff --git a/IrisGlass/gradle.properties b/IrisGlass/gradle.properties new file mode 100644 index 0000000..4387edc --- /dev/null +++ b/IrisGlass/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/IrisGlass/gradle/libs.versions.toml b/IrisGlass/gradle/libs.versions.toml new file mode 100644 index 0000000..c3aac0a --- /dev/null +++ b/IrisGlass/gradle/libs.versions.toml @@ -0,0 +1,18 @@ +[versions] +agp = "8.13.2" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +appcompat = "1.6.1" +material = "1.10.0" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } + diff --git a/IrisGlass/gradle/wrapper/gradle-wrapper.jar b/IrisGlass/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/IrisGlass/gradle/wrapper/gradle-wrapper.jar differ diff --git a/IrisGlass/gradle/wrapper/gradle-wrapper.properties b/IrisGlass/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..69da012 --- /dev/null +++ b/IrisGlass/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Tue Jan 06 23:06:52 GMT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/IrisGlass/gradlew b/IrisGlass/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/IrisGlass/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/IrisGlass/gradlew.bat b/IrisGlass/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/IrisGlass/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/IrisGlass/settings.gradle b/IrisGlass/settings.gradle new file mode 100644 index 0000000..edab088 --- /dev/null +++ b/IrisGlass/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Iris Glass" +include ':app'