From d89aedd5af4d209a690bb60c37295c0b7158d4b5 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 8 Jan 2026 19:16:32 +0000 Subject: [PATCH] initial commit --- .gitignore | 293 +++++++++ IrisCompanion/iris.xcodeproj/project.pbxproj | 582 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../iris/Assets.xcassets/Contents.json | 6 + .../iris/Bluetooth/BlePeripheralManager.swift | 431 +++++++++++++ IrisCompanion/iris/ContentView.swift | 36 ++ .../iris/DataSources/CalendarDataSource.swift | 200 ++++++ .../iris/DataSources/POIDataSource.swift | 33 + .../iris/DataSources/WeatherDataSource.swift | 334 ++++++++++ IrisCompanion/iris/Info.plist | 24 + IrisCompanion/iris/Models/Candidate.swift | 61 ++ IrisCompanion/iris/Models/FeedEnvelope.swift | 193 ++++++ IrisCompanion/iris/Models/FeedStore.swift | 192 ++++++ .../iris/Models/HeuristicRanker.swift | 81 +++ .../Models/WeatherKitConditionCoding.swift | 50 ++ IrisCompanion/iris/Models/Winner.swift | 37 ++ .../iris/Models/WinnerEnvelope.swift | 100 +++ IrisCompanion/iris/Network/LocalServer.swift | 383 ++++++++++++ .../Orchestrator/ContextOrchestrator.swift | 563 +++++++++++++++++ .../ProtocolFixtures/all_quiet_example.json | 16 + .../ProtocolFixtures/full_feed_fixture.json | 1 + .../ProtocolFixtures/poi_nearby_example.json | 16 + .../ProtocolFixtures/transit_example.json | 16 + .../weather_alert_rain_soon.json | 16 + .../weather_warning_example.json | 16 + IrisCompanion/iris/Utils/DataTrimming.swift | 25 + .../iris/ViewModels/CandidatesViewModel.swift | 71 +++ IrisCompanion/iris/Views/BleStatusView.swift | 124 ++++ IrisCompanion/iris/Views/CandidatesView.swift | 120 ++++ .../iris/Views/OrchestratorView.swift | 176 ++++++ IrisCompanion/iris/iris.entitlements | 8 + IrisCompanion/iris/irisApp.swift | 28 + IrisCompanion/irisTests/irisTests.swift | 17 + IrisCompanion/irisUITests/irisUITests.swift | 41 ++ .../irisUITests/irisUITestsLaunchTests.swift | 33 + IrisGlass/README.md | 70 +++ IrisGlass/app/.gitignore | 1 + IrisGlass/app/build.gradle | 49 ++ IrisGlass/app/proguard-rules.pro | 21 + .../irisglass/ExampleInstrumentedTest.java | 26 + IrisGlass/app/src/main/AndroidManifest.xml | 57 ++ .../java/sh/nym/irisglass/ActionActivity.java | 147 +++++ .../sh/nym/irisglass/BleCentralClient.java | 536 ++++++++++++++++ .../java/sh/nym/irisglass/BleLinkService.java | 125 ++++ .../java/sh/nym/irisglass/BucketType.java | 14 + .../main/java/sh/nym/irisglass/Constants.java | 30 + .../java/sh/nym/irisglass/FeedActivity.java | 140 +++++ .../java/sh/nym/irisglass/FeedAdapter.java | 144 +++++ .../java/sh/nym/irisglass/FeedEnvelope.java | 29 + .../main/java/sh/nym/irisglass/FeedItem.java | 53 ++ .../java/sh/nym/irisglass/FeedItemType.java | 25 + .../main/java/sh/nym/irisglass/FeedMeta.java | 12 + .../main/java/sh/nym/irisglass/FeedModel.java | 14 + .../sh/nym/irisglass/FeedModelBuilder.java | 38 ++ .../java/sh/nym/irisglass/FeedParser.java | 63 ++ .../sh/nym/irisglass/FeedReassembler.java | 519 ++++++++++++++++ .../java/sh/nym/irisglass/HudService.java | 350 +++++++++++ .../main/java/sh/nym/irisglass/HudState.java | 87 +++ .../java/sh/nym/irisglass/MainActivity.java | 15 + .../java/sh/nym/irisglass/MenuActivity.java | 183 ++++++ .../sh/nym/irisglass/SettingsActivity.java | 137 +++++ .../sh/nym/irisglass/SuppressionStore.java | 32 + .../sh/nym/irisglass/WeatherCondition.java | 80 +++ .../java/sh/nym/irisglass/WeatherInfo.java | 30 + .../sh/nym/irisglass/WeatherInfoParser.java | 269 ++++++++ .../java/sh/nym/irisglass/WeatherV2Icons.java | 143 +++++ .../drawable-nodpi/weather_v2_blizzard.png | Bin 0 -> 1897 bytes .../weather_v2_blowing_snow.png | Bin 0 -> 1215 bytes .../drawable-nodpi/weather_v2_clear_night.png | Bin 0 -> 1615 bytes .../res/drawable-nodpi/weather_v2_cloudy.png | Bin 0 -> 1544 bytes .../res/drawable-nodpi/weather_v2_drizzle.png | Bin 0 -> 1677 bytes .../drawable-nodpi/weather_v2_flurries.png | Bin 0 -> 2111 bytes .../weather_v2_haze_fog_dust_smoke.png | Bin 0 -> 1889 bytes .../drawable-nodpi/weather_v2_heavy_rain.png | Bin 0 -> 2232 bytes .../drawable-nodpi/weather_v2_heavy_snow.png | Bin 0 -> 2565 bytes ...ther_v2_isolated_scattered_tstorms_day.png | Bin 0 -> 3511 bytes ...er_v2_isolated_scattered_tstorms_night.png | Bin 0 -> 3397 bytes .../weather_v2_mostly_clear_night.png | Bin 0 -> 2601 bytes .../weather_v2_mostly_cloudy_day.png | Bin 0 -> 2406 bytes .../weather_v2_mostly_cloudy_night.png | Bin 0 -> 2384 bytes .../weather_v2_mostly_sunny.png | Bin 0 -> 3513 bytes .../weather_v2_partly_cloudy.png | Bin 0 -> 4147 bytes .../weather_v2_partly_cloudy_night.png | Bin 0 -> 2371 bytes .../weather_v2_scattered_showers_day.png | Bin 0 -> 2568 bytes .../weather_v2_scattered_showers_night.png | Bin 0 -> 2527 bytes .../weather_v2_showers_rain.png | Bin 0 -> 2061 bytes .../drawable-nodpi/weather_v2_sleet_hail.png | Bin 0 -> 1823 bytes .../weather_v2_snow_showers_snow.png | Bin 0 -> 2328 bytes .../weather_v2_strong_tstorms.png | Bin 0 -> 3109 bytes .../res/drawable-nodpi/weather_v2_sunny.png | Bin 0 -> 2393 bytes .../res/drawable-nodpi/weather_v2_tornado.png | Bin 0 -> 766 bytes .../weather_v2_wintry_mix_rain_snow.png | Bin 0 -> 2721 bytes .../app/src/main/res/layout/hud_live_card.xml | 141 +++++ .../app/src/main/res/menu/feed_actions.xml | 32 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values-night/themes.xml | 20 + IrisGlass/app/src/main/res/values/colors.xml | 10 + IrisGlass/app/src/main/res/values/strings.xml | 3 + IrisGlass/app/src/main/res/values/themes.xml | 20 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../sh/nym/irisglass/ExampleUnitTest.java | 17 + IrisGlass/build.gradle | 4 + IrisGlass/gradle.properties | 21 + IrisGlass/gradle/libs.versions.toml | 18 + IrisGlass/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + IrisGlass/gradlew | 251 ++++++++ IrisGlass/gradlew.bat | 94 +++ IrisGlass/settings.gradle | 23 + 121 files changed, 8509 insertions(+) create mode 100644 .gitignore create mode 100644 IrisCompanion/iris.xcodeproj/project.pbxproj create mode 100644 IrisCompanion/iris.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 IrisCompanion/iris/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 IrisCompanion/iris/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 IrisCompanion/iris/Assets.xcassets/Contents.json create mode 100644 IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift create mode 100644 IrisCompanion/iris/ContentView.swift create mode 100644 IrisCompanion/iris/DataSources/CalendarDataSource.swift create mode 100644 IrisCompanion/iris/DataSources/POIDataSource.swift create mode 100644 IrisCompanion/iris/DataSources/WeatherDataSource.swift create mode 100644 IrisCompanion/iris/Info.plist create mode 100644 IrisCompanion/iris/Models/Candidate.swift create mode 100644 IrisCompanion/iris/Models/FeedEnvelope.swift create mode 100644 IrisCompanion/iris/Models/FeedStore.swift create mode 100644 IrisCompanion/iris/Models/HeuristicRanker.swift create mode 100644 IrisCompanion/iris/Models/WeatherKitConditionCoding.swift create mode 100644 IrisCompanion/iris/Models/Winner.swift create mode 100644 IrisCompanion/iris/Models/WinnerEnvelope.swift create mode 100644 IrisCompanion/iris/Network/LocalServer.swift create mode 100644 IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift create mode 100644 IrisCompanion/iris/ProtocolFixtures/all_quiet_example.json create mode 100644 IrisCompanion/iris/ProtocolFixtures/full_feed_fixture.json create mode 100644 IrisCompanion/iris/ProtocolFixtures/poi_nearby_example.json create mode 100644 IrisCompanion/iris/ProtocolFixtures/transit_example.json create mode 100644 IrisCompanion/iris/ProtocolFixtures/weather_alert_rain_soon.json create mode 100644 IrisCompanion/iris/ProtocolFixtures/weather_warning_example.json create mode 100644 IrisCompanion/iris/Utils/DataTrimming.swift create mode 100644 IrisCompanion/iris/ViewModels/CandidatesViewModel.swift create mode 100644 IrisCompanion/iris/Views/BleStatusView.swift create mode 100644 IrisCompanion/iris/Views/CandidatesView.swift create mode 100644 IrisCompanion/iris/Views/OrchestratorView.swift create mode 100644 IrisCompanion/iris/iris.entitlements create mode 100644 IrisCompanion/iris/irisApp.swift create mode 100644 IrisCompanion/irisTests/irisTests.swift create mode 100644 IrisCompanion/irisUITests/irisUITests.swift create mode 100644 IrisCompanion/irisUITests/irisUITestsLaunchTests.swift create mode 100644 IrisGlass/README.md create mode 100644 IrisGlass/app/.gitignore create mode 100644 IrisGlass/app/build.gradle create mode 100644 IrisGlass/app/proguard-rules.pro create mode 100644 IrisGlass/app/src/androidTest/java/sh/nym/irisglass/ExampleInstrumentedTest.java create mode 100644 IrisGlass/app/src/main/AndroidManifest.xml create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/ActionActivity.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/BleCentralClient.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/BucketType.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedActivity.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedAdapter.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedEnvelope.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItem.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItemType.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedMeta.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModel.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModelBuilder.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedParser.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/FeedReassembler.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/HudService.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/HudState.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/MainActivity.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/MenuActivity.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/SettingsActivity.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/SuppressionStore.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherCondition.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfo.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherInfoParser.java create mode 100644 IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherV2Icons.java create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blizzard.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_blowing_snow.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_clear_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_cloudy.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_drizzle.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_flurries.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_haze_fog_dust_smoke.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_rain.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_heavy_snow.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_day.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_isolated_scattered_tstorms_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_clear_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_day.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_cloudy_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_mostly_sunny.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_partly_cloudy_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_day.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_scattered_showers_night.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_showers_rain.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sleet_hail.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_snow_showers_snow.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_strong_tstorms.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sunny.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_tornado.png create mode 100644 IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_wintry_mix_rain_snow.png create mode 100644 IrisGlass/app/src/main/res/layout/hud_live_card.xml create mode 100644 IrisGlass/app/src/main/res/menu/feed_actions.xml create mode 100644 IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 IrisGlass/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 IrisGlass/app/src/main/res/values-night/themes.xml create mode 100644 IrisGlass/app/src/main/res/values/colors.xml create mode 100644 IrisGlass/app/src/main/res/values/strings.xml create mode 100644 IrisGlass/app/src/main/res/values/themes.xml create mode 100644 IrisGlass/app/src/main/res/xml/backup_rules.xml create mode 100644 IrisGlass/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 IrisGlass/app/src/test/java/sh/nym/irisglass/ExampleUnitTest.java create mode 100644 IrisGlass/build.gradle create mode 100644 IrisGlass/gradle.properties create mode 100644 IrisGlass/gradle/libs.versions.toml create mode 100644 IrisGlass/gradle/wrapper/gradle-wrapper.jar create mode 100644 IrisGlass/gradle/wrapper/gradle-wrapper.properties create mode 100755 IrisGlass/gradlew create mode 100644 IrisGlass/gradlew.bat create mode 100644 IrisGlass/settings.gradle 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 0000000000000000000000000000000000000000..ea77059964dddb4e068ca66a71faa8c923bc1131 GIT binary patch literal 1897 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1e1d6e83b30c53c501742f4536bf947c15c9524d GIT binary patch literal 1615 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 XiQLC9Mt_gt00000NkvXXu0mjf3!g@& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..86a6a551c3dbe4da8aded4542deae74479e2bc3a GIT binary patch literal 2111 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1e8a341bd601e8482aeb8a2d2aff95eb9a6f6fc8 GIT binary patch literal 3397 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 AfdBvi literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b35ee13570795771c04c97570f390e398ecf5e4f GIT binary patch literal 2601 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 LNkvXXu0mjfCYofX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fb802a0b9a6cefa43a2157b19ab5a0d6f3560d0b GIT binary patch literal 2406 zcmV-s37PhZP)FDd~>Fet1>FMd~>+A0B?(XjH>+0(L*+1pq-RtS-?d|RA>gwa#*!$K& z@!qQ8*wyXpFM>`dHvZu{M9}A z&^`UwJ^k4{{ntC+-{0fo%+;+1c6K+uPsT z*y7*b+}qpR*VWwH+uGOGpL?hP0000?bW%=J02w%DlD5m_|5^Y4|1kds|Nk0Bd8o?b z^Y{Dz*Z*a`|NmM4T`vFq|2hBv|NsA(|NsBt|NsB=|NsC0|NsC0|NsC0|NoV|+_mw{ zfZ?Mz000N&Nkl~{^34s(x&dJ0ssI200000c#hiom}^UW z&Cy#epQIjB7u%5;VO!EXYWWDYZ^@Cj!rHx-cXv+7amjmsX|1!ov$p=3FVF2pVIT@` zako!suHZ5p%K8R%jhi2E6HgaRz| z1iXIN{N#*6Vv0~Wx{tqrfvYFr^$fq!9_A4-4Tv6}vIZC>4S`MD&tENw23%KFVAD4=M9zp!2fqym~B6KgB^r9-rkuE6G^H$b6X0l*HQ z9o*Kypuos>e(-WiJHQahCP;6De5yu(y`=&ls6Zyr(tU;57r-9h*5J(dIA?-%&*(!1 z`2l-6-PYioz#vDUQLhu4t>Bd2QelugL7Q$J3qS=JqB9rZYvAPxXnf3#E#;R~fJ-Dg zK=L!VW>Ei}_A4v^hX?{6XOJUMyLUkLU2w_>{F5AL9HFqP{D7UX$HE}t(Q{DW;h?$# zz%H#oz>{VRD6|5rU(A_>lH4#1KvVAEF*7r$96M@aJA3f|KUDAP_@p=``QGpjy*Dz= zM$w-rjja%1V?DA&tnVHj{XE?J^zqie|3hL--G8HQoHO3~fQa|MMq<$U;is)HkvI9? z(U8Q%0^_|8u>hVh`oebn<;$ylXAmR_bz2Gn6tRA^SM+8<)t{*A)>$cl5|$4#-D1#( zL0u1=96$}Tlf42~Gx&pvBWR4T!~oh2XJhJzDD)%5fI(v@KxSjE#}MW-_`}91ps|Sn zfQ`*$fR;ZKFlc-t0C8hRaSua(ntBi?(3CzEfUwpbaG?n^qGJ%;#}3?Pr#WRunZA(e?SUoh5;_5 z0ubcWbPh)7=@VGf8XAqj@CEn)m=y0ZAoTQ!`hEmb0X}3-I*JzH7DfO20P;5$JN$`@ zg6E-uf;}I=79f#YMDQ2|eI|e{z$dJUOXx8g_K~`N5Wp4SQ{X_@#0>x5UYvl7*byGZ z0Ag7>MtS~~JP+Ovz!=LEAj5%pk;(kMIZvPfM*&O$GOY1Wi7Zqt6Y!t_Ibe)^%w@rn zebPNm=^B8vie{OBF%Qb%@D-gH zfKV`3yo7_)mo$J*f4*RW3pfa%i%ShaK8xlH4y(U{j7trGFIZ*+&OjBLIzl_{gEcl_ z(t{?BHb68Dk6>d`$0%_Hid4?A{-I(?Tx7(K2R+Ql-*f)yg3TiP2m(DpS^spwL6Lm~ z-rnfrBSHuKb2`%qu%|y?U<-*!2ReB2X9Lm&*M-Pg01ZOgpCz$}BV!F{;61IL#6Qu$ zn6HyuM`0k0<8=c|PPRQEWnk4}$KYZp+r-2D&(q^NF{EtX`<>t~^a1u0TEcTDYRXgp z=l^Cu26)Al9}$vLo1cIBLV5g%68lLZ2`^o`(gRBDCnZ9{N=-?c*-sZpajGc?bz=W1 z4w6LB1qY;g{ZvDmu-Fum*iTx7E#!<%5n40*7YUnO=2HF0>Q`sjsJ8eC`_T#5>SFZ) z{kTsjVLNG5f&H`khYI!(feL&-8U3`vKEibw|6lxm54){a=bR5R@{yu|{Vs+w^<%mI z#EfRmpARB)J~(&*Z#tQU+0W0%ZiV-WpO)D9tbS!HV3E#JlMU|GI|*DDXNM2!Cwmr< z!6lkSAD^%0C?ONMPIfr9pXOLYPW8jgy*?;kFD70fH*xZlnR`__pE~QlN+?5Ak1!eA zEdwc~F=xJ3L_;^*$yXA}lJ(g~7s8ZPaM7<-Eq}KM2Lb>901SifPkn+JR0{wA00000 Y2SLGkQPjkP4*&oF07*qoM6N<$f)p9DmjD0& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b85064a5a176497681d990ca033a7bf641cecaa3 GIT binary patch literal 2384 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3dc512d8692b9000b7307ae5f0c6bf4d0fc7567a GIT binary patch literal 3513 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9e3d5c4dc5bdd1548342ebd023ea218434307a79 GIT binary patch literal 2568 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5612b2b2d89c6f70b830d29e39543013d17e9a8a GIT binary patch literal 2527 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 r-@ov{_bZWNe)00000NkvXXu0mjfQ*tjm literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8a2817e438c72cd3677ba9d6601be9f3a27a6c1a GIT binary patch literal 1823 zcmV+)2jKXLP) z_4V)X@8jd+>FMd|>FMd|>Few3>+9?7?d|RD?d0U-gww2>+9s? z+9?0=H}z$X`TPI0gG3Kv|K>9POaK4>|NsC0 z|M1+1h5!G53PJuyam+9P00u8fL_t(|0qmOTnyV-fKufc41g(IDnDqO<<)o`8v1h>y zNEzq*a{k`!oQA^f!iRJ|eE9I;Ls2f4tMz8P+uzx3)`#P%fd32Xykc+toBf^YSisp6qVLv15j_@R-iUILjdZXdXxWFCxJ65C^kAr0P~hzROx&HdNX~&T4xMEUBSdG zb=CmXhpDf>nFCNSU_#VR=MKQVn*RVZ7-9pHanuq4dO7a6hCIBm#gj!1l z=o3uAMl1lgFaakyf7EA?rxFZ6GxrJLfcH+pTEKtGtC`ZW0opd!8bZ|jKiRBT%SEjq z6HDO$bZy%-)?4q02pN8-T`g2T<2G z*8WQXqpQ=5cMzkyZXke2uhe|cAg29Z0`E;AfN;duk@E!pXuGZ*3Lw&l0@7Gb5#Tyr zf`I_SN+phfygv^?doTFc0D7<~A%Q}C0hpEpymJEqgf+xdPU4SE*M^pWdbSB`u$aUj zEeG5S1_LPJ`_yup0K-GT1tEW?DyBSyFalnIJC<*0MFAsO=1jP z_Xx0q0fcI*_YoKw&=-XL6E@Ly8h>7aM}Qv;z!StE5Ra(-J0OIiQ~a#!!f(*zGhX()>PyUKgyT9%wKXo`4^H^BW4F;J;Fk zv_A8k0QL_TBpjrE3V-5&egR$zI9|8Z5 z06vSx-40n%dxpY0C~%r?Akz?F7Hn__1+NDY0X50V_ak5fJ19`#AOefu$fz^aAEEBS zi?gVL2#zR>eO|*{Fu`;VWxN>*VA5_S#FxPF zkN;bq9=HH0H*?I%p&ZZor6=xHfh!=6D~ds3|8T(?BB`zL0=VITo`e}@4umq8pZ!mS zPv|u@#8NaU1K&P4B8u_15YO2NE&xR?z!4I-Im{0wZ6K5O!MGLh=iAR%f4V^ub%VJv zK4}4JNaN_`{lyl5S>-Ko4}XgC_cbJQ%UM8a{&av5Tn%QU_m2u_6pWy_n!Nte17H^b z;|hExf1C94H%l1H&F0$w3I6U@z-U(E3tXZ{hOQG{cre9Z%pqA-V@S0 z5O>?X&`OY?(g+(f6F`lt>AWluXp=f-sx`zxB44@diz`6 z@9zsZ{<^G=MOprFS`U~a%0HyTfD|Ihe>e^pLZtXh&c^}skTb>K3XTIf{>E`Y0{I)I zu8vwr{zh^IHPQU7fbDMuRDWO48q?n>X{W&a{Spne{5n-Y_BZJMp6=iMZ>0N+{;~b7 zfbDMuY=0|Y`&$9o-%x-*3_o)u7eM{p%#mEc^mj8yG^#foQ3eu2h-`lma)C~1V^V=K zFKuU;+5T3*_O}A2Ke<4c=2%ov#Hk%HMU;O??SK>_$zP5G1XPgoi2!>RRB#+%{v1vP z2#QjhqZWaYTtQ7DyaKkr6|nuSfb8#%C}YWazktabf(GDErqk(kI{%)pAHKW(V8#Fd N002ovPDHLkV1k@&jQ;=t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..909cb134a6fef52d8606d7dffaae5d060d3fce93 GIT binary patch literal 2328 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..894d1ea60e28eb75e305910297f6cc1897a5f764 GIT binary patch literal 3109 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 LNkvXXu0mjfM^De9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..829b7a8f870a963acfdc1220ffd612070125731c GIT binary patch literal 766 zcmVvhppRz{|(46fYn&O+|O{BAyf=AJvVfd?pZ&N=6tbJJF~ zquRE-*_@*}!<_jX$%LJe18yU3K-d{M;5On0gq@KBcmWl#qiaCe89CrK+6T-v3g87$ za{?7`j0D_+zDn}7oZ!`0rC#a=s7iC7vKSFpgxxuL{E^p0_u&PuQtR3 zE`l1I)AQ+ubU+H~v!1UDkQ0dP1U0Vb(+%l>cc4Ze7CcH?c#L+ObI!T<(;ZBqxydub zlH>EGzn0oNwD7GQFt16;PPD0TpTa?UyDrVD0L*m^D6br}05+BFz^A=*0_ zJ1ANTV;iDeg|+eA=K~K!uE5$L@{NH9@=K6EZUAu~qj!jMgz*0iJ7I40L3U-46!u=` wkFXAVV}B~d3&0`CAcjGbfpg9|=bY=}2PIMK{_SJK6aWAK07*qoM6N<$f-HY%>Hq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fcd0eea51aed453fc24c171be8b29471e17feadc GIT binary patch literal 2721 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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'