initial commit
293
.gitignore
vendored
Normal file
@@ -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
|
||||
582
IrisCompanion/iris.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||
};
|
||||
F1779CAE2F0DAC9B009C6626 /* irisTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = irisTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1779CB82F0DAC9B009C6626 /* irisUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = irisUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
F1779C9F2F0DAC9A009C6626 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1779C9E2F0DAC9A009C6626 /* iris.app */,
|
||||
F1779CAB2F0DAC9B009C6626 /* irisTests.xctest */,
|
||||
F1779CB52F0DAC9B009C6626 /* irisUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
IrisCompanion/iris.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
IrisCompanion/iris/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
431
IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift
Normal file
@@ -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<UUID>()
|
||||
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..<end]
|
||||
)
|
||||
if peripheral.updateValue(packet, for: characteristic, onSubscribedCentrals: nil) {
|
||||
publish { self.lastNotifyAt = Date() }
|
||||
message.nextChunkIndex += 1
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func flushPendingMessages() {
|
||||
guard let feedTx = feedTx else { return }
|
||||
while !pendingMessages.isEmpty {
|
||||
if sendPendingMessage(&pendingMessages[0], characteristic: feedTx) {
|
||||
pendingMessages.removeFirst()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
publishNotifyQueueDepth()
|
||||
}
|
||||
|
||||
private func enqueuePendingMessage(_ message: PendingMessage) {
|
||||
pendingMessages.append(message)
|
||||
enforceNotifyQueueLimits()
|
||||
publishNotifyQueueDepth()
|
||||
}
|
||||
|
||||
private func publishNotifyQueueDepth() {
|
||||
let depth = pendingMessages.reduce(0) { $0 + $1.remainingChunks }
|
||||
publish { self.notifyQueueDepth = depth }
|
||||
}
|
||||
|
||||
private func queuedNotifyBytesApprox() -> 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`.
|
||||
}
|
||||
36
IrisCompanion/iris/ContentView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
200
IrisCompanion/iris/DataSources/CalendarDataSource.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
IrisCompanion/iris/DataSources/POIDataSource.swift
Normal file
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
334
IrisCompanion/iris/DataSources/WeatherDataSource.swift
Normal file
@@ -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",
|
||||
]
|
||||
}
|
||||
}
|
||||
24
IrisCompanion/iris/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Allow Bluetooth to send context updates to Glass.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Allow calendar access to show upcoming events on Glass.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Allow full calendar access to show upcoming events on Glass.</string>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>Allow access to your Now Playing information to show music on Glass.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Allow location access to compute context cards for Glass.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Allow background location access to keep Glass context updated while moving.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
61
IrisCompanion/iris/Models/Candidate.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
193
IrisCompanion/iris/Models/FeedEnvelope.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
192
IrisCompanion/iris/Models/FeedStore.swift
Normal file
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
81
IrisCompanion/iris/Models/HeuristicRanker.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
IrisCompanion/iris/Models/WeatherKitConditionCoding.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
37
IrisCompanion/iris/Models/Winner.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
100
IrisCompanion/iris/Models/WinnerEnvelope.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
383
IrisCompanion/iris/Network/LocalServer.swift
Normal file
@@ -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[..<range.lowerBound])
|
||||
}
|
||||
|
||||
private func handleRequest(requestLine: String, connection: NWConnection) {
|
||||
let parts = requestLine.split(separator: " ")
|
||||
guard parts.count >= 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<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let firstAddr = addrList else {
|
||||
return results
|
||||
}
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var ptr: UnsafeMutablePointer<ifaddrs>? = 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()
|
||||
}
|
||||
}
|
||||
563
IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift
Normal file
@@ -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<T>(seconds: Double, operation: @escaping () async throws -> T) async -> Result<T, Error> {
|
||||
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
|
||||
}
|
||||
}
|
||||
16
IrisCompanion/iris/ProtocolFixtures/all_quiet_example.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
16
IrisCompanion/iris/ProtocolFixtures/poi_nearby_example.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
16
IrisCompanion/iris/ProtocolFixtures/transit_example.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
25
IrisCompanion/iris/Utils/DataTrimming.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
71
IrisCompanion/iris/ViewModels/CandidatesViewModel.swift
Normal file
@@ -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+."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
IrisCompanion/iris/Views/BleStatusView.swift
Normal file
@@ -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).")
|
||||
}
|
||||
}
|
||||
120
IrisCompanion/iris/Views/CandidatesView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
176
IrisCompanion/iris/Views/OrchestratorView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
8
IrisCompanion/iris/iris.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
28
IrisCompanion/iris/irisApp.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
IrisCompanion/irisTests/irisTests.swift
Normal file
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
41
IrisCompanion/irisUITests/irisUITests.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
IrisCompanion/irisUITests/irisUITestsLaunchTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
70
IrisGlass/README.md
Normal file
@@ -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`.
|
||||
1
IrisGlass/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
49
IrisGlass/app/build.gradle
Normal file
@@ -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.")
|
||||
}
|
||||
}
|
||||
21
IrisGlass/app/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
@@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
}
|
||||
57
IrisGlass/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity
|
||||
android:name=".FeedActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.IrisGlass.Fullscreen"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity
|
||||
android:name=".ActionActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.IrisGlass.Fullscreen"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity
|
||||
android:name=".MenuActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.IrisGlass.MenuOverlay"
|
||||
android:launchMode="singleTask"
|
||||
android:clearTaskOnLaunch="true"
|
||||
android:excludeFromRecents="true" />
|
||||
|
||||
<service android:name=".HudService" />
|
||||
<service android:name=".BleLinkService" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
147
IrisGlass/app/src/main/java/sh/nym/irisglass/ActionActivity.java
Normal file
@@ -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<String> actionList = new ArrayList<String>();
|
||||
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<String> actions;
|
||||
|
||||
ActionAdapter(Activity activity, String title, List<String> actions) {
|
||||
this.activity = activity;
|
||||
this.title = title != null ? title : "";
|
||||
this.actions = actions != null ? actions : new ArrayList<String>();
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> services;
|
||||
|
||||
private Advert(String localName, List<UUID> 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();
|
||||
}
|
||||
}
|
||||
125
IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java
Normal file
@@ -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<FeedItem> 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);
|
||||
}
|
||||
}
|
||||
14
IrisGlass/app/src/main/java/sh/nym/irisglass/BucketType.java
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
30
IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java
Normal file
@@ -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";
|
||||
}
|
||||
140
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedActivity.java
Normal file
@@ -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<FeedItem> makeWaitingCard() {
|
||||
ArrayList<FeedItem> items = new ArrayList<FeedItem>();
|
||||
items.add(new FeedItem(
|
||||
"local:empty",
|
||||
FeedItemType.INFO,
|
||||
"No cards",
|
||||
"Waiting for feed…",
|
||||
0.0,
|
||||
1,
|
||||
"",
|
||||
new ArrayList<String>()
|
||||
));
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<FeedItem> buildVisibleItems(HudState.Snapshot snapshot) {
|
||||
if (snapshot == null) return makeWaitingCard();
|
||||
FeedEnvelope env = new FeedEnvelope(1, 0L, snapshot.items, snapshot.meta);
|
||||
List<FeedItem> items = env.activeItems();
|
||||
if (items.isEmpty()) return makeWaitingCard();
|
||||
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
ArrayList<FeedItem> filtered = new ArrayList<FeedItem>();
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
144
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedAdapter.java
Normal file
@@ -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<FeedItem> items;
|
||||
|
||||
public FeedAdapter(Context context, List<FeedItem> items) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.items = new ArrayList<FeedItem>();
|
||||
if (items != null) this.items.addAll(items);
|
||||
sortInPlace(this.items);
|
||||
}
|
||||
|
||||
public List<FeedItem> 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<FeedItem> 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<FeedItem> items) {
|
||||
if (items == null) return;
|
||||
Collections.sort(items, new Comparator<FeedItem>() {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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<FeedItem> items;
|
||||
public final FeedMeta meta;
|
||||
|
||||
public FeedEnvelope(int schema, long generatedAtEpochSeconds, List<FeedItem> items, FeedMeta meta) {
|
||||
this.schema = schema;
|
||||
this.generatedAtEpochSeconds = generatedAtEpochSeconds;
|
||||
this.items = items != null ? items : new ArrayList<FeedItem>();
|
||||
this.meta = meta;
|
||||
}
|
||||
|
||||
public List<FeedItem> activeItems() {
|
||||
ArrayList<FeedItem> 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;
|
||||
}
|
||||
}
|
||||
|
||||
53
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedItem.java
Normal file
@@ -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<String> actions;
|
||||
public final JSONObject raw;
|
||||
|
||||
public FeedItem(
|
||||
String id,
|
||||
String type,
|
||||
String title,
|
||||
String subtitle,
|
||||
double priority,
|
||||
int ttlSec,
|
||||
String bucket,
|
||||
List<String> 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<String> 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<String>();
|
||||
this.raw = raw;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
12
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedMeta.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
14
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedModel.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FeedItem> 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);
|
||||
}
|
||||
}
|
||||
|
||||
63
IrisGlass/app/src/main/java/sh/nym/irisglass/FeedParser.java
Normal file
@@ -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<FeedItem> items = new ArrayList<FeedItem>();
|
||||
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<String> actions = new ArrayList<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, byte[]> chunksByOffset = new TreeMap<Integer, byte[]>();
|
||||
|
||||
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<Integer, byte[]> 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<Integer, byte[]> 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<Integer, Assembly> assemblies = new HashMap<Integer, Assembly>();
|
||||
private StreamAssembly stream;
|
||||
private final HashMap<Integer, ChunkAssembly> chunkAssemblies = new HashMap<Integer, ChunkAssembly>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
350
IrisGlass/app/src/main/java/sh/nym/irisglass/HudService.java
Normal file
@@ -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<FeedItem> 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() : "";
|
||||
}
|
||||
}
|
||||
87
IrisGlass/app/src/main/java/sh/nym/irisglass/HudState.java
Normal file
@@ -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<FeedItem> items;
|
||||
public final FeedMeta meta;
|
||||
|
||||
private Snapshot(
|
||||
List<FeedItem> items,
|
||||
FeedMeta meta
|
||||
) {
|
||||
this.items = items != null ? items : Collections.<FeedItem>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<Listener> listeners = new ArrayList<Listener>();
|
||||
|
||||
private List<FeedItem> 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<FeedItem> items, FeedMeta meta, boolean shouldRender) {
|
||||
List<Listener> listenerCopy;
|
||||
Snapshot snapshot;
|
||||
synchronized (lock) {
|
||||
ArrayList<FeedItem> copy = new ArrayList<FeedItem>();
|
||||
if (items != null) copy.addAll(items);
|
||||
this.items = Collections.unmodifiableList(copy);
|
||||
this.meta = meta;
|
||||
snapshot = new Snapshot(this.items, this.meta);
|
||||
listenerCopy = new ArrayList<Listener>(listeners);
|
||||
}
|
||||
notifyListeners(listenerCopy, snapshot, shouldRender);
|
||||
}
|
||||
|
||||
private void notifyListeners(final List<Listener> 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
183
IrisGlass/app/src/main/java/sh/nym/irisglass/MenuActivity.java
Normal file
@@ -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<String> allowedActions = new HashSet<String>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 : "-";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
143
IrisGlass/app/src/main/java/sh/nym/irisglass/WeatherV2Icons.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
BIN
IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_cloudy.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_drizzle.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
BIN
IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_sunny.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
IrisGlass/app/src/main/res/drawable-nodpi/weather_v2_tornado.png
Normal file
|
After Width: | Height: | Size: 766 B |
|
After Width: | Height: | Size: 2.7 KiB |
141
IrisGlass/app/src/main/res/layout/hud_live_card.xml
Normal file
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/hud_cols"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/hud_left_col"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_time"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-thin"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="64sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_day"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#808080"
|
||||
android:textSize="24sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_date"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#808080"
|
||||
android:textSize="24sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/hud_weather_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/hud_weather_icon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:scaleType="centerInside"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_weather_temp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="32sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/hud_right_col"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginLeft="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_more_badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_alignTop="@id/hud_title"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textColor="#34A7FF"
|
||||
android:textSize="28sp"
|
||||
android:singleLine="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_toLeftOf="@id/hud_more_badge"
|
||||
android:layout_marginRight="12dp"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="32sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/hud_title"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#808080"
|
||||
android:textSize="24sp" />
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hud_status_dot"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:fontFamily="sans-serif"
|
||||
android:includeFontPadding="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="#808080"
|
||||
android:textSize="8sp" />
|
||||
|
||||
</RelativeLayout>
|
||||
32
IrisGlass/app/src/main/res/menu/feed_actions.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/action_dismiss"
|
||||
android:icon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:title="Dismiss"
|
||||
android:titleCondensed="Dismiss" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_snooze_2h"
|
||||
android:icon="@android:drawable/ic_menu_recent_history"
|
||||
android:title="Snooze 2 hours"
|
||||
android:titleCondensed="Snooze 2 hours" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_snooze_24h"
|
||||
android:icon="@android:drawable/ic_menu_recent_history"
|
||||
android:title="Snooze 24 hours"
|
||||
android:titleCondensed="Snooze 24 hours" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:icon="@android:drawable/ic_menu_save"
|
||||
android:title="Save"
|
||||
android:titleCondensed="Save" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_back"
|
||||
android:icon="@android:drawable/ic_menu_revert"
|
||||
android:title="Back"
|
||||
android:titleCondensed="Back" />
|
||||
</menu>
|
||||
BIN
IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
IrisGlass/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
IrisGlass/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |