init
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: '@react-native',
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Xcode
|
||||||
|
!**/*.xcodeproj
|
||||||
|
!**/*.pbxproj
|
||||||
|
!**/*.xcworkspacedata
|
||||||
|
!**/*.xcsettings
|
||||||
|
!**/*.xcscheme
|
||||||
|
*.pbxuser
|
||||||
|
!default.pbxuser
|
||||||
|
*.mode1v3
|
||||||
|
!default.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
!default.mode2v3
|
||||||
|
*.perspectivev3
|
||||||
|
!default.perspectivev3
|
||||||
|
xcuserdata
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
DerivedData
|
||||||
|
*.hmap
|
||||||
|
*.ipa
|
||||||
|
*.xcuserstate
|
||||||
|
project.xcworkspace
|
||||||
|
**/.xcode.env.local
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
/build/
|
||||||
|
/packages/rn-tester/build
|
||||||
|
/packages/rn-tester/android/app/.cxx/
|
||||||
|
/packages/rn-tester/android/app/build/
|
||||||
|
/packages/rn-tester/android/app/gradle/
|
||||||
|
/packages/rn-tester/android/app/gradlew
|
||||||
|
/packages/rn-tester/android/app/gradlew.bat
|
||||||
|
/packages/react-native/build/
|
||||||
|
/packages/react-native/ReactAndroid/build/
|
||||||
|
/packages/react-native/ReactAndroid/.cxx/
|
||||||
|
/packages/react-native/ReactAndroid/gradle/
|
||||||
|
/packages/react-native/ReactAndroid/gradlew
|
||||||
|
/packages/react-native/ReactAndroid/gradlew.bat
|
||||||
|
/packages/react-native/ReactAndroid/external-artifacts/build/
|
||||||
|
/packages/react-native/ReactAndroid/external-artifacts/artifacts/
|
||||||
|
/packages/react-native/ReactAndroid/hermes-engine/build/
|
||||||
|
/packages/react-native/ReactAndroid/hermes-engine/.cxx/
|
||||||
|
/private/helloworld/android/app/build/
|
||||||
|
/private/helloworld/android/build/
|
||||||
|
/packages/react-native-popup-menu-android/android/build/
|
||||||
|
|
||||||
|
# Buck
|
||||||
|
.buckd
|
||||||
|
buck-out
|
||||||
|
/.lsp.buckd
|
||||||
|
/.lsp-buck-out
|
||||||
|
/packages/react-native/ReactAndroid/src/main/jni/prebuilt/lib/
|
||||||
|
/packages/react-native/ReactAndroid/src/main/gen
|
||||||
|
/.cpplsp.buckd
|
||||||
|
|
||||||
|
# Android Studio
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# Watchman
|
||||||
|
.watchmanconfig
|
||||||
|
|
||||||
|
# Android
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
/packages/react-native/android/*
|
||||||
|
!/packages/react-native/android/README.md
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
.nvm
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# OS X
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Test generated files
|
||||||
|
*.js.meta
|
||||||
|
|
||||||
|
/coverage
|
||||||
|
/third-party
|
||||||
|
|
||||||
|
# Test Reports
|
||||||
|
/reports
|
||||||
|
|
||||||
|
# Stack Dumps generated when programs crash (Ex. bash.exe.stackdump on Win)
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Root dir shouldn't have Xcode project
|
||||||
|
/*.xcodeproj
|
||||||
|
|
||||||
|
# ReactCommon subdir shouldn't have Xcode project
|
||||||
|
/packages/react-native/ReactCommon/**/*.xcodeproj
|
||||||
|
|
||||||
|
# Libs that shouldn't have Xcode project
|
||||||
|
/packages/react-native/Libraries/FBLazyVector/**/*.xcodeproj
|
||||||
|
/packages/react-native/Libraries/Required/**/*.xcodeproj
|
||||||
|
/packages/react-native/React/CoreModules/**/*.xcodeproj
|
||||||
|
/packages/react-native/React/FBReactNativeSpec/**/*.xcodeproj
|
||||||
|
/packages/react-native-codegen/**/*.xcodeproj
|
||||||
|
/packages/rn-tester/**/*.xcodeproj
|
||||||
|
|
||||||
|
# Ruby Gems (Bundler)
|
||||||
|
/packages/react-native/vendor
|
||||||
|
/private/helloworld/vendor
|
||||||
|
.ruby-version
|
||||||
|
/**/.ruby-version
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# iOS / CocoaPods
|
||||||
|
/private/helloworld/ios/build/
|
||||||
|
/private/helloworld/ios/Pods/
|
||||||
|
/private/helloworld/ios/Podfile.lock
|
||||||
|
/packages/rn-tester/bin/
|
||||||
|
/packages/rn-tester/cache/
|
||||||
|
/packages/rn-tester/extensions/
|
||||||
|
/packages/rn-tester/gems/
|
||||||
|
/packages/rn-tester/specifications/
|
||||||
|
/packages/rn-tester/Gemfile.lock
|
||||||
|
/packages/**/RCTLegacyInteropComponents.mm
|
||||||
|
/packages/sourcemap.ios.map
|
||||||
|
|
||||||
|
# Ignore RNTester specific Pods, but keep the __offline_mirrors__ here.
|
||||||
|
/packages/rn-tester/Pods/*
|
||||||
|
!/packages/rn-tester/Pods/__offline_mirrors_hermes__
|
||||||
|
!/packages/rn-tester/Pods/__offline_mirrors_jsc__
|
||||||
|
|
||||||
|
# Swift Package build folder
|
||||||
|
/packages/react-native/.build
|
||||||
|
/packages/react-native/.swiftpm
|
||||||
|
/packages/react-native/React/includes/
|
||||||
|
|
||||||
|
# @react-native/codegen
|
||||||
|
/packages/react-native/React/FBReactNativeSpec/
|
||||||
|
/packages/react-native-codegen/lib
|
||||||
|
/packages/react-native-codegen/tmp/
|
||||||
|
/packages/rn-tester/NativeModuleExample/ScreenshotManagerSpec*
|
||||||
|
/**/RCTThirdPartyFabricComponentsProvider.*
|
||||||
|
|
||||||
|
# @react-native/codegen-typescript-test
|
||||||
|
/private/react-native-codegen-typescript-test/lib
|
||||||
|
|
||||||
|
# Additional SDKs
|
||||||
|
/packages/react-native/sdks/download
|
||||||
|
/packages/react-native/sdks/hermes
|
||||||
|
/packages/react-native/sdks/hermesc
|
||||||
|
/packages/react-native/sdks/hermes-engine/hermes-engine-from-local-source-dir.tar.gz
|
||||||
|
|
||||||
|
# iOS prebuilds
|
||||||
|
/packages/react-native/third-party/
|
||||||
|
fix_*.patch
|
||||||
|
*.xcframework
|
||||||
|
|
||||||
|
# Visual Studio Code (config dir - if present, this merges user defined
|
||||||
|
# workspace settings on top of react-native.code-workspace)
|
||||||
|
/.vscode/*
|
||||||
|
!/.vscode/tasks.json
|
||||||
|
!/.vscode/launch.json
|
||||||
|
|
||||||
|
# Visual Studio
|
||||||
|
.vs
|
||||||
|
|
||||||
|
# Android memory profiler files
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# Jest Integration
|
||||||
|
/private/react-native-fantom/build/
|
||||||
|
/private/react-native-fantom/.out/
|
||||||
|
/private/react-native-fantom/tester/build/
|
||||||
|
|
||||||
|
# [Experimental] Generated TS type definitions
|
||||||
|
/packages/**/types_generated/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Doxygen XML output used for C++ API tracking
|
||||||
|
/packages/react-native/**/api/xml
|
||||||
|
/packages/react-native/**/api/codegen
|
||||||
|
/packages/react-native/**/.doxygen.config.*.generated
|
||||||
|
/scripts/cxx-api/codegen
|
||||||
|
|
||||||
|
# Android — build output only. The native alarm engine (Kotlin, manifest,
|
||||||
|
# resources) is source and must be tracked; the RN template's blanket
|
||||||
|
# `android/**` rule would silently exclude it.
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/.gradle/
|
||||||
|
android/.cxx/
|
||||||
|
android/app/.cxx/
|
||||||
|
android/local.properties
|
||||||
|
android/app/debug.keystore.bak
|
||||||
|
android/app/release/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# iOS — build output only
|
||||||
|
ios/build/
|
||||||
|
ios/Pods/
|
||||||
|
ios/*.xcworkspace/xcuserdata/
|
||||||
|
|
||||||
|
# Device / emulator / build scratch used by this client's local tooling
|
||||||
|
.bunhome/
|
||||||
|
.buntmp/
|
||||||
|
.gradle-home/
|
||||||
|
.ccache/
|
||||||
|
.build-logs/
|
||||||
|
.xdg-data/
|
||||||
|
.xdg-runtime/
|
||||||
|
.kotlin-tmp/
|
||||||
|
.android-home/
|
||||||
|
.avd/
|
||||||
|
*.hprof
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Copyright (C) {{.Year}} {{.Holder}}
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# addlicense ignore patterns (doublestar globs, full relative paths).
|
||||||
|
# Blank lines and lines starting with # are skipped. No negation (!) support.
|
||||||
|
|
||||||
|
# --- tooling / generated ---
|
||||||
|
**/node_modules/**
|
||||||
|
android/**
|
||||||
|
ios/**
|
||||||
|
.bundle/**
|
||||||
|
.vscode/**
|
||||||
|
.buntmp/**
|
||||||
|
.bunhome/**
|
||||||
|
**/*.md
|
||||||
|
**/*.json
|
||||||
|
**/*.lock
|
||||||
|
**/*.yaml
|
||||||
|
**/*.yml
|
||||||
|
.licenseignore
|
||||||
|
.prettierrc.js
|
||||||
|
.eslintrc.js
|
||||||
|
.watchmanconfig
|
||||||
|
babel.config.js
|
||||||
|
metro.config.js
|
||||||
|
jest.config.js
|
||||||
|
global.css
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
arrowParens: 'avoid',
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// Alarm client — house launch set. No React Native debug extension: JS
|
||||||
|
// debugging happens through Metro's dev menu / Hermes inspector.
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Maestro",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "bunx react-native run-android --port 8089 --no-packager",
|
||||||
|
"preLaunchTask": "Setup adb reverse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Metro",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "bun start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
// Alarm client (bare React Native, no Expo) — house task set.
|
||||||
|
// Metro is pinned to 127.0.0.1:8089 (see android/gradle.properties and
|
||||||
|
// package.json's `start` script). 8088 is NOT Metro here: it is the Alarm
|
||||||
|
// service's documented host port, so direct mode reverses it separately and
|
||||||
|
// the two can never fight over one host port.
|
||||||
|
// device 127.0.0.1:8089 -> host 127.0.0.1:8089 (Metro packager)
|
||||||
|
// device 127.0.0.1:8088 -> host 127.0.0.1:8088 (Alarm direct)
|
||||||
|
// device 127.0.0.1:8080 -> host 127.0.0.1:8080 (Maestro)
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Start Metro",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "metro",
|
||||||
|
"pattern": {
|
||||||
|
"regexp": "^(.*)$",
|
||||||
|
"file": 1,
|
||||||
|
"location": 1,
|
||||||
|
"message": 1
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": "Welcome to Metro|Starting Metro Bundler",
|
||||||
|
"endsPattern": "Metro waiting on keystroke|Dev server running"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Setup adb reverse",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "adb reverse tcp:8089 tcp:8089 && adb reverse tcp:8088 tcp:8088 && adb reverse tcp:8080 tcp:8080 && adb reverse --list",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Launch Android",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bash scripts/android.sh",
|
||||||
|
"dependsOn": "Setup adb reverse",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Lint",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun run lint",
|
||||||
|
"problemMatcher": ["$eslint-stylish"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Test",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun run test",
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Typecheck",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bunx tsc --noEmit",
|
||||||
|
"problemMatcher": ["$tsc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Build Debug APK (arm64-v8a)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd android && ./gradlew :app:assembleDebug -PreactNativeArchitectures=arm64-v8a",
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { ThemeProvider } from './src/state/ThemeContext';
|
||||||
|
import { ConnectionProvider } from './src/state/ConnectionContext';
|
||||||
|
import { AuthProvider, useAuth } from './src/state/AuthContext';
|
||||||
|
import { AlarmProvider } from './src/state/AlarmContext';
|
||||||
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
|
import HomeScreen from './src/screens/HomeScreen';
|
||||||
|
import AlertDetailScreen from './src/screens/AlertDetailScreen';
|
||||||
|
import QrDisplayScreen from './src/screens/QrDisplayScreen';
|
||||||
|
import ScannerScreen from './src/screens/ScannerScreen';
|
||||||
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
|
import LiveAlarmScreen from './src/screens/LiveAlarmScreen';
|
||||||
|
import { LoadingView } from './src/components/ui';
|
||||||
|
import { navigationRef, openLiveAlarm } from './src/navigationRef';
|
||||||
|
import { getLaunchAlert } from './src/services/alarmNative';
|
||||||
|
import type { RootStackParamList } from './src/navigation';
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
|
function Navigator({ initialRoute }: { initialRoute: 'Home' | 'Login' }) {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator initialRouteName={initialRoute} screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<Stack.Screen name="Home" component={HomeScreen} />
|
||||||
|
<Stack.Screen name="AlertDetail" component={AlertDetailScreen} />
|
||||||
|
<Stack.Screen name="QrDisplay" component={QrDisplayScreen} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="Scanner"
|
||||||
|
component={ScannerScreen}
|
||||||
|
options={{ animation: 'slide_from_bottom' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
|
{/*
|
||||||
|
The takeover is a modal presentation with no gesture dismissal and no
|
||||||
|
back affordance: while an alarm is live it is the only reachable surface
|
||||||
|
(spec §9, Cross-screen rules).
|
||||||
|
*/}
|
||||||
|
<Stack.Screen
|
||||||
|
name="LiveAlarm"
|
||||||
|
component={LiveAlarmScreen}
|
||||||
|
options={{
|
||||||
|
presentation: 'fullScreenModal',
|
||||||
|
animation: 'fade',
|
||||||
|
gestureEnabled: false,
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A native full-screen launch (`AlarmActivity`, started by the armed foreground
|
||||||
|
* service) carries the alert id in its intent. Read it once on boot and open the
|
||||||
|
* takeover, so a trigger that cold-starts the process still lands on the alarm
|
||||||
|
* rather than the home screen. Ordinary launches resolve `null`.
|
||||||
|
*
|
||||||
|
* Runs inside the navigator: the intent read is async, but `navigationRef` is
|
||||||
|
* not ready on the first frame, so a launch that arrives early is retried until
|
||||||
|
* the container is up (bounded — it is a boot path, not a poll).
|
||||||
|
*/
|
||||||
|
function useNativeLaunchTakeover() {
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const open = (params: RootStackParamList['LiveAlarm']) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (navigationRef.isReady()) {
|
||||||
|
openLiveAlarm(params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts++ > 20) return;
|
||||||
|
timer = setTimeout(() => open(params), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
getLaunchAlert()
|
||||||
|
.then(launch => {
|
||||||
|
if (cancelled || !launch) return;
|
||||||
|
open({
|
||||||
|
alertId: launch.alertId,
|
||||||
|
message: launch.message || undefined,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No native module (iOS / JS-only run) — nothing to do.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const { token, booting } = useAuth();
|
||||||
|
const prevToken = useRef(token);
|
||||||
|
|
||||||
|
// Single navigator owns every route; auth state is driven by navigation
|
||||||
|
// resets rather than remounting whole navigators. A reset always replaces
|
||||||
|
// the whole stack, so Login/Home is the only route left, with no back stack.
|
||||||
|
useEffect(() => {
|
||||||
|
if (token === prevToken.current) return;
|
||||||
|
prevToken.current = token;
|
||||||
|
if (token) {
|
||||||
|
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
||||||
|
} else {
|
||||||
|
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
||||||
|
}
|
||||||
|
}, [token, navigation]);
|
||||||
|
|
||||||
|
useNativeLaunchTakeover();
|
||||||
|
|
||||||
|
if (booting) return <LoadingView label="Starting…" />;
|
||||||
|
return (
|
||||||
|
<AlarmProvider>
|
||||||
|
<Navigator initialRoute={token ? 'Home' : 'Login'} />
|
||||||
|
</AlarmProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<HeroUINativeProviderRaw>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ConnectionProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<NavigationContainer ref={navigationRef}>
|
||||||
|
<Root />
|
||||||
|
</NavigationContainer>
|
||||||
|
</AuthProvider>
|
||||||
|
</ConnectionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</HeroUINativeProviderRaw>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
|
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
||||||
|
ruby ">= 2.6.10"
|
||||||
|
|
||||||
|
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||||
|
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||||
|
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||||
|
gem 'xcodeproj', '< 1.26.0'
|
||||||
|
gem 'concurrent-ruby', '< 1.3.4'
|
||||||
|
|
||||||
|
# Ruby 3.4.0 has removed some libraries from the standard library.
|
||||||
|
gem 'bigdecimal'
|
||||||
|
gem 'logger'
|
||||||
|
gem 'benchmark'
|
||||||
|
gem 'mutex_m'
|
||||||
|
gem 'nkf'
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
calendar-client
|
||||||
|
Copyright (C) 2026 admin
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# alarm-client
|
||||||
|
|
||||||
|
Android client for the Dcntrlized **Alarm** service: one person (the alert owner)
|
||||||
|
can make other people's phones ring at maximum volume, live, in under a second.
|
||||||
|
|
||||||
|
Built as a bare React Native app (no Expo) with the same stack as
|
||||||
|
`keepnote-client` / `calendar-client`: RN 0.86, React 19, TypeScript,
|
||||||
|
react-navigation, heroUI Native + uniwind, async-storage, lucide-react-native.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Owner**: create an alert, show its QR code, hold-to-trigger it (1200ms +
|
||||||
|
confirm), disarm it, rotate the subscribe token, revoke devices, delete it.
|
||||||
|
- **Subscriber**: scan the QR (or paste the code by hand), subscribe this device,
|
||||||
|
and get a full-screen takeover when the owner triggers — over the lock screen,
|
||||||
|
at maximum alarm volume, with vibration.
|
||||||
|
- **Live delivery** is a WebSocket (`/ws/alarm`) while the app is in the
|
||||||
|
foreground, plus an Android foreground service that keeps its own socket open
|
||||||
|
while the app is backgrounded. When the connection or proxy mode cannot carry
|
||||||
|
the socket, the client falls back to polling `alarmStatus` (5s foreground,
|
||||||
|
30s background) and says so in Settings → Live delivery.
|
||||||
|
|
||||||
|
## Connection modes
|
||||||
|
|
||||||
|
The client mirrors the house pattern in `src/services/connection.ts`:
|
||||||
|
|
||||||
|
| Mode | How it reaches the service | WebSocket |
|
||||||
|
|---|---|---|
|
||||||
|
| `maestro` (default) | probe `/api/containers`, address the service through its `serviceRef`, actions at `POST {base}/api/actions/{serviceRef}/{action}` | **not available** — Maestro has no service WS passthrough, so the client polls `alarmStatus` and Settings says "live delivery unavailable in proxy mode" |
|
||||||
|
| `direct` | probe the service's `/_manifest` and talk to it directly | available: `ws://<host>:<port>/ws/alarm?alertId=…&token=…` |
|
||||||
|
|
||||||
|
Dev host port for the service is **8088** (prod: `https://alarm.dcntrlized.org`).
|
||||||
|
This app's **Metro** runs on **8089**. The two must never share a number: both
|
||||||
|
are reached over `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev
|
||||||
|
machine's* `127.0.0.1:<port>` — one host socket, one owner. Per-client Metro
|
||||||
|
ports across the house: keepnote `8082`, hub `8084`, calendar `8086`, alarm
|
||||||
|
`8089`; the service ports stay put (alarm `8088`, Maestro `8080`).
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
bun start # Metro on 127.0.0.1:8089
|
||||||
|
bash scripts/android.sh # adb reverse + build + install + launch
|
||||||
|
```
|
||||||
|
|
||||||
|
VS Code entrypoints (same scheme as the other clients): tasks `Start Metro`,
|
||||||
|
`Setup adb reverse`, `Launch Android`, `Lint`, `Test`, `Typecheck`,
|
||||||
|
`Build Debug APK (arm64-v8a)`; launches `Debug Maestro` (with the reverse task)
|
||||||
|
and `Debug Metro`.
|
||||||
|
|
||||||
|
### Gradle in a memory-constrained environment
|
||||||
|
|
||||||
|
The repo keeps `org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m`,
|
||||||
|
`org.gradle.workers.max=2` and `kotlin.daemon.jvmargs=-Xmx512m`, and build
|
||||||
|
artifacts/daemons live inside the workspace so nothing needs `$HOME`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
GRADLE_USER_HOME=$PWD/../.gradle-home \
|
||||||
|
CCACHE_DIR=$PWD/../.ccache CCACHE_TEMPDIR=$PWD/../.ccache/tmp \
|
||||||
|
XDG_DATA_HOME=$PWD/../.xdg-data TMPDIR=$PWD/../.kotlin-tmp \
|
||||||
|
./gradlew :app:assembleDebug -PreactNativeArchitectures=arm64-v8a --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## The native alarm engine (Android)
|
||||||
|
|
||||||
|
Kotlin, under `android/app/src/main/java/com/dcntrlized/alarm/`:
|
||||||
|
|
||||||
|
| File | Job |
|
||||||
|
|---|---|
|
||||||
|
| `AlarmPlayer.kt` | `MediaPlayer` on `USAGE_ALARM`/`STREAM_ALARM`, looped, volume forced to max and **read back**; `RingtoneManager` fallback; `VibrationEffect` waveform; auto-stop cap. Reports honestly whether the alarm stream was actually raised. |
|
||||||
|
| `AlarmNotifier.kt` | `alarm_trigger` (IMPORTANCE_HIGH, bypassDnd, PUBLIC, full-screen intent, "Raise your volume" action when the stream was blocked) and `alarm_armed` (IMPORTANCE_LOW) channels. |
|
||||||
|
| `AlarmForegroundService.kt` | Holds its **own** OkHttp WebSocket while armed (so a trigger lands with the app backgrounded), shows the persistent "Alarm armed" notification, and on a trigger plays the alarm and launches the takeover. |
|
||||||
|
| `AlarmActivity.kt` | `ReactActivity` for the takeover: `showWhenLocked` + `turnScreenOn`, keyguard dismissal, back consumed, player stopped on destroy. |
|
||||||
|
| `AlarmModule.kt` / `AlarmPackage.kt` | RN bridge (`playAlarm`, `stopAlarm`, `startService`, `stopService`, `getPermissions`, `requestPermission`, `getLaunchAlert`, `openSoundSettings`, `openAppSettings`, `openFullScreenIntentSettings`, `openExactAlarmSettings`). |
|
||||||
|
|
||||||
|
The **escalation ladder** is deliberate and honest: max-volume `STREAM_ALARM` →
|
||||||
|
vibration → high-priority notification. If Android refuses the alarm stream (DND,
|
||||||
|
volume locked, full-screen intent revoked), the app does not claim it forced the
|
||||||
|
volume — the takeover shows a warning strip with a **Raise volume** action, and
|
||||||
|
the notification carries the "Raise your volume" action too.
|
||||||
|
|
||||||
|
Manifest permissions: `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`,
|
||||||
|
`SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`, `VIBRATE`, `WAKE_LOCK`,
|
||||||
|
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC` +
|
||||||
|
`FOREGROUND_SERVICE_MEDIA_PLAYBACK`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`,
|
||||||
|
`CAMERA`, `INTERNET`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx tsc --noEmit # 0 errors
|
||||||
|
bun run lint # 0 errors (25 react-native/no-inline-styles warnings)
|
||||||
|
bun run test # 46 tests: QR payload parser, WS reconnect machine, time formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
The WebSocket client (`src/services/alarmSocket.ts`) is a pure module with an
|
||||||
|
injectable socket factory, so its reconnect ramp (1s→2s→4s→8s→16s→30s cap, ±25%
|
||||||
|
jitter, reset on open) and frame parsing are tested headlessly.
|
||||||
|
|
||||||
|
**Runtime behaviour still needs a device**: the lock-screen takeover, the volume
|
||||||
|
ladder, vibration, haptics, camera capture, and every animation claim in
|
||||||
|
`planning/service/alarm/04-android-client/01-alarm-ux.md` §8 are verified only by
|
||||||
|
source reading and a compile, not by observation.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0. New source files carry the header from `.license-header.tpl`.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* WS reconnect state machine + frame contract tests.
|
||||||
|
*
|
||||||
|
* The ramp under test is the one the service team must expect from the client:
|
||||||
|
* 1s → 2s → 4s → 8s → 16s → 30s (cap), jittered ±25%, reset on a successful
|
||||||
|
* open.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AlarmSocket,
|
||||||
|
DEFAULT_RECONNECT_POLICY,
|
||||||
|
buildAck,
|
||||||
|
buildPing,
|
||||||
|
parseFrame,
|
||||||
|
ReconnectMachine,
|
||||||
|
type AlarmFrame,
|
||||||
|
} from '../src/services/alarmSocket';
|
||||||
|
|
||||||
|
// A deterministic "random" that always returns the midpoint, so jitter is 1.0×
|
||||||
|
// and the ramp is exactly the nominal one.
|
||||||
|
const midpoint = () => 0.5;
|
||||||
|
|
||||||
|
describe('ReconnectMachine ramp', () => {
|
||||||
|
it('follows 1s→2s→4s→8s→16s→30s and saturates at the cap', () => {
|
||||||
|
const m = new ReconnectMachine({}, midpoint);
|
||||||
|
expect(m.nominalDelayFor(1)).toBe(1000);
|
||||||
|
expect(m.nominalDelayFor(2)).toBe(2000);
|
||||||
|
expect(m.nominalDelayFor(3)).toBe(4000);
|
||||||
|
expect(m.nominalDelayFor(4)).toBe(8000);
|
||||||
|
expect(m.nominalDelayFor(5)).toBe(16000);
|
||||||
|
expect(m.nominalDelayFor(6)).toBe(30000);
|
||||||
|
expect(m.nominalDelayFor(7)).toBe(30000);
|
||||||
|
expect(m.nominalDelayFor(50)).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the configured jitter band', () => {
|
||||||
|
const low = new ReconnectMachine({}, () => 0).nominalDelayFor(3);
|
||||||
|
const high = new ReconnectMachine({}, () => 1).nominalDelayFor(3);
|
||||||
|
const lo = new ReconnectMachine({}, () => 0).delayFor(3);
|
||||||
|
const hi = new ReconnectMachine({}, () => 1).delayFor(3);
|
||||||
|
expect(lo).toBe(Math.round(low * (1 - DEFAULT_RECONNECT_POLICY.jitter)));
|
||||||
|
expect(hi).toBe(Math.round(high * (1 + DEFAULT_RECONNECT_POLICY.jitter)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the same delay through onClosed as delayFor(attempt)', () => {
|
||||||
|
const m = new ReconnectMachine({}, midpoint);
|
||||||
|
expect(m.onClosed()).toBe(1000);
|
||||||
|
expect(m.onClosed()).toBe(2000);
|
||||||
|
expect(m.onClosed()).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the ramp after a successful open', () => {
|
||||||
|
const m = new ReconnectMachine({}, midpoint);
|
||||||
|
m.onClosed();
|
||||||
|
m.onClosed();
|
||||||
|
m.onClosed();
|
||||||
|
expect(m.getAttempts()).toBe(3);
|
||||||
|
m.onOpened();
|
||||||
|
expect(m.getAttempts()).toBe(0);
|
||||||
|
expect(m.getState()).toBe('open');
|
||||||
|
expect(m.onClosed()).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks the state machine idle → connecting → open → reconnecting', () => {
|
||||||
|
const m = new ReconnectMachine({}, midpoint);
|
||||||
|
expect(m.getState()).toBe('idle');
|
||||||
|
m.onConnecting();
|
||||||
|
expect(m.getState()).toBe('connecting');
|
||||||
|
m.onOpened();
|
||||||
|
expect(m.getState()).toBe('open');
|
||||||
|
m.onClosed();
|
||||||
|
expect(m.getState()).toBe('reconnecting');
|
||||||
|
m.onClosedPermanently();
|
||||||
|
expect(m.getState()).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative delay', () => {
|
||||||
|
const m = new ReconnectMachine({ baseMs: 0 }, midpoint);
|
||||||
|
expect(m.delayFor(1)).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frame contract', () => {
|
||||||
|
it('parses a trigger frame', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
type: 'alarm',
|
||||||
|
alertId: 'alr_1',
|
||||||
|
eventId: 'evt_9',
|
||||||
|
action: 'trigger',
|
||||||
|
message: 'Fire drill',
|
||||||
|
serverTime: '2026-10-18T17:00:00Z',
|
||||||
|
autoStopSeconds: 60,
|
||||||
|
});
|
||||||
|
expect(parseFrame(raw)).toEqual({
|
||||||
|
type: 'alarm',
|
||||||
|
alertId: 'alr_1',
|
||||||
|
eventId: 'evt_9',
|
||||||
|
action: 'trigger',
|
||||||
|
message: 'Fire drill',
|
||||||
|
serverTime: '2026-10-18T17:00:00Z',
|
||||||
|
autoStopSeconds: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a disarm frame', () => {
|
||||||
|
const frame = parseFrame(
|
||||||
|
JSON.stringify({ type: 'alarm', alertId: 'a', eventId: 'e', action: 'disarm' }),
|
||||||
|
);
|
||||||
|
expect(frame.type).toBe('alarm');
|
||||||
|
expect(frame.type === 'alarm' && frame.action).toBe('disarm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses ack and ping frames', () => {
|
||||||
|
expect(parseFrame('{"type":"ack","eventId":"e1"}')).toEqual({ type: 'ack', eventId: 'e1' });
|
||||||
|
expect(parseFrame('{"type":"ping"}')).toEqual({ type: 'ping' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades malformed / unknown frames to unknown instead of throwing', () => {
|
||||||
|
for (const junk of [
|
||||||
|
'',
|
||||||
|
'not json',
|
||||||
|
'[]',
|
||||||
|
'null',
|
||||||
|
'{"type":"alarm"}',
|
||||||
|
'{"type":"alarm","alertId":"a","eventId":"e","action":"explode"}',
|
||||||
|
'{"type":"ack"}',
|
||||||
|
'{"type":"something-else"}',
|
||||||
|
]) {
|
||||||
|
expect(parseFrame(junk)).toEqual({ type: 'unknown' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the client→server frames exactly', () => {
|
||||||
|
expect(buildAck('evt_9')).toBe('{"type":"ack","eventId":"evt_9"}');
|
||||||
|
expect(buildPing()).toBe('{"type":"ping"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A minimal fake WebSocket so the wrapper is exercised without a network ───
|
||||||
|
class FakeSocket {
|
||||||
|
static last: FakeSocket | null = null;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
sent: string[] = [];
|
||||||
|
closed = false;
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeSocket.last = this;
|
||||||
|
}
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
// test helpers
|
||||||
|
open() {
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
message(data: unknown) {
|
||||||
|
this.onmessage?.({ data });
|
||||||
|
}
|
||||||
|
drop() {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AlarmSocket wrapper', () => {
|
||||||
|
const url = 'ws://127.0.0.1:8088/ws/alarm?alertId=alr_1&token=sub_x';
|
||||||
|
|
||||||
|
it('acks a received alarm frame automatically', () => {
|
||||||
|
const alarms: AlarmFrame[] = [];
|
||||||
|
const socket = new AlarmSocket({
|
||||||
|
url,
|
||||||
|
onAlarm: f => alarms.push(f),
|
||||||
|
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
|
||||||
|
random: midpoint,
|
||||||
|
pingIntervalMs: 0,
|
||||||
|
});
|
||||||
|
socket.connect();
|
||||||
|
const ws = FakeSocket.last!;
|
||||||
|
ws.open();
|
||||||
|
ws.message(JSON.stringify({ type: 'alarm', alertId: 'alr_1', eventId: 'evt_1', action: 'trigger' }));
|
||||||
|
expect(alarms).toHaveLength(1);
|
||||||
|
expect(ws.sent).toEqual(['{"type":"ack","eventId":"evt_1"}']);
|
||||||
|
expect(socket.getState()).toBe('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules a reconnect with the ramp delay after a drop', () => {
|
||||||
|
const delays: number[] = [];
|
||||||
|
const socket = new AlarmSocket({
|
||||||
|
url,
|
||||||
|
onAlarm: () => {},
|
||||||
|
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
|
||||||
|
random: midpoint,
|
||||||
|
pingIntervalMs: 0,
|
||||||
|
setTimer: (fn, ms) => {
|
||||||
|
delays.push(ms);
|
||||||
|
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
clearTimer: () => {},
|
||||||
|
});
|
||||||
|
socket.connect();
|
||||||
|
FakeSocket.last!.open();
|
||||||
|
FakeSocket.last!.drop();
|
||||||
|
FakeSocket.last!.drop();
|
||||||
|
expect(delays).toEqual([1000, 2000]);
|
||||||
|
expect(socket.getState()).toBe('reconnecting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops reconnecting once closed by the user', () => {
|
||||||
|
let timers = 0;
|
||||||
|
const socket = new AlarmSocket({
|
||||||
|
url,
|
||||||
|
onAlarm: () => {},
|
||||||
|
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
|
||||||
|
random: midpoint,
|
||||||
|
pingIntervalMs: 0,
|
||||||
|
setTimer: () => {
|
||||||
|
timers += 1;
|
||||||
|
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
clearTimer: () => {},
|
||||||
|
});
|
||||||
|
socket.connect();
|
||||||
|
FakeSocket.last!.open();
|
||||||
|
socket.close();
|
||||||
|
expect(socket.getState()).toBe('closed');
|
||||||
|
expect(timers).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send when the socket is not open', () => {
|
||||||
|
const socket = new AlarmSocket({
|
||||||
|
url,
|
||||||
|
onAlarm: () => {},
|
||||||
|
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
|
||||||
|
random: midpoint,
|
||||||
|
pingIntervalMs: 0,
|
||||||
|
});
|
||||||
|
socket.connect();
|
||||||
|
expect(socket.send('{"type":"ping"}')).toBe(false);
|
||||||
|
FakeSocket.last!.open();
|
||||||
|
expect(socket.send('{"type":"ping"}')).toBe(true);
|
||||||
|
expect(FakeSocket.last!.sent).toEqual(['{"type":"ping"}']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Error plumbing: the service answers `{"error":{"code":…,"message":…}}`, and
|
||||||
|
* the join path has to tell the user something true without naming a mechanism
|
||||||
|
* it cannot observe. These tests pin both halves.
|
||||||
|
*/
|
||||||
|
import { ApiError, configureApi, post } from '../src/services/apiClient';
|
||||||
|
import { joinErrorMessage } from '../src/services/alarmApi';
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { fetch: typeof fetch };
|
||||||
|
const realFetch = g.fetch;
|
||||||
|
|
||||||
|
function stubFetch(body: string, status = 400): void {
|
||||||
|
g.fetch = (async () => ({
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
text: async () => body,
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configureApi({
|
||||||
|
baseUrl: 'http://alarm.test',
|
||||||
|
mode: 'direct',
|
||||||
|
serviceRef: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
onUnauthorized: () => {},
|
||||||
|
onSessionUpdate: () => {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
g.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiClient error unwrapping', () => {
|
||||||
|
it('extracts the service error code and the clean message', async () => {
|
||||||
|
stubFetch(
|
||||||
|
JSON.stringify({
|
||||||
|
error: { code: 'ERR_ALERT_TOKEN_INVALID', message: 'invalid subscribe token' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(post('joinAlert', {})).rejects.toMatchObject({
|
||||||
|
name: 'ApiError',
|
||||||
|
status: 400,
|
||||||
|
code: 'ERR_ALERT_TOKEN_INVALID',
|
||||||
|
message: 'invalid subscribe token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a non-JSON body as the message and reports no code', async () => {
|
||||||
|
stubFetch('Bad Gateway', 502);
|
||||||
|
await expect(post('joinAlert', {})).rejects.toMatchObject({
|
||||||
|
status: 502,
|
||||||
|
code: null,
|
||||||
|
message: 'Bad Gateway',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinErrorMessage', () => {
|
||||||
|
it('renders a token problem as a remedy, never as "expired"', () => {
|
||||||
|
// JoinAlert returns this one code for a rotated token AND for a malformed
|
||||||
|
// one, so the client cannot say which happened.
|
||||||
|
const err = new ApiError(400, 'invalid subscribe token', 'ERR_ALERT_TOKEN_INVALID');
|
||||||
|
const copy = joinErrorMessage(err);
|
||||||
|
expect(copy).toBe('This code is no longer valid — ask the owner for a new one.');
|
||||||
|
expect(copy).not.toMatch(/expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through any other server message', () => {
|
||||||
|
expect(joinErrorMessage(new ApiError(404, 'alert not found', 'ERR_NOT_FOUND'))).toBe(
|
||||||
|
'alert not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back for errors that are not ApiError instances', () => {
|
||||||
|
expect(joinErrorMessage(new Error('boom'))).toBe('boom');
|
||||||
|
expect(joinErrorMessage(undefined)).toBe('Could not subscribe this device.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* QR payload parser tests — the frozen payload contract.
|
||||||
|
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
buildQrPayload,
|
||||||
|
buildQrSchemePayload,
|
||||||
|
parseManualToken,
|
||||||
|
parseQrPayload,
|
||||||
|
qrErrorMessage,
|
||||||
|
truncateToken,
|
||||||
|
QR_HOST,
|
||||||
|
} from '../src/lib/qrPayload';
|
||||||
|
|
||||||
|
const ID = 'alr_7f3c9a1b2d';
|
||||||
|
const TOKEN = 'sub_9Qx2mZp4Lk7VeR1sTn0';
|
||||||
|
|
||||||
|
describe('buildQrPayload', () => {
|
||||||
|
it('renders the canonical contract payload', () => {
|
||||||
|
expect(buildQrPayload(ID, TOKEN)).toBe(`https://${QR_HOST}/a/${ID}#${TOKEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the custom-scheme variant', () => {
|
||||||
|
expect(buildQrSchemePayload(ID, TOKEN)).toBe(`dcntrlized-alarm://a/${ID}#${TOKEN}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseQrPayload', () => {
|
||||||
|
it('parses the canonical payload', () => {
|
||||||
|
const res = parseQrPayload(buildQrPayload(ID, TOKEN));
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: true,
|
||||||
|
payload: { alertId: ID, subscribeToken: TOKEN, source: 'url' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the custom scheme', () => {
|
||||||
|
const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN));
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||||
|
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||||
|
expect(res.ok && res.payload.source).toBe('scheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a relative a/{id}#{token} payload', () => {
|
||||||
|
const res = parseQrPayload(`a/${ID}#${TOKEN}`);
|
||||||
|
expect(res.ok && res.payload.source).toBe('relative');
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a raw {id}#{token} payload (what the owner reads off the panel)', () => {
|
||||||
|
const res = parseQrPayload(`${ID}#${TOKEN}`);
|
||||||
|
expect(res.ok && res.payload.source).toBe('raw');
|
||||||
|
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates surrounding whitespace and a trailing slash', () => {
|
||||||
|
const res = parseQrPayload(` https://${QR_HOST}/a/${ID}/#${TOKEN} `);
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates query noise before the fragment', () => {
|
||||||
|
const res = parseQrPayload(`https://${QR_HOST}/a/${ID}?utm=x#${TOKEN}`);
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||||
|
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a subdomain of the alarm host (staging)', () => {
|
||||||
|
const res = parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts localhost / LAN dev payloads', () => {
|
||||||
|
expect(parseQrPayload(`http://127.0.0.1:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||||
|
expect(parseQrPayload(`http://192.168.1.232:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a foreign host', () => {
|
||||||
|
expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'not-an-alarm-code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a foreign scheme', () => {
|
||||||
|
expect(parseQrPayload(`ftp://${QR_HOST}/a/${ID}#${TOKEN}`)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'not-an-alarm-code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an empty payload', () => {
|
||||||
|
expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing token', () => {
|
||||||
|
expect(parseQrPayload(`https://${QR_HOST}/a/${ID}`)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'missing-token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing alert id', () => {
|
||||||
|
expect(parseQrPayload(`https://${QR_HOST}/a/#${TOKEN}`)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'missing-alert-id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws on garbage', () => {
|
||||||
|
for (const junk of ['###', 'https://', '://a/b#c', '\u0000', '{"json":true}']) {
|
||||||
|
expect(() => parseQrPayload(junk)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips ids that need encoding', () => {
|
||||||
|
const weird = 'alr/with space';
|
||||||
|
const built = buildQrPayload(weird, TOKEN);
|
||||||
|
const res = parseQrPayload(built);
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(weird);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseManualToken', () => {
|
||||||
|
it('accepts a bare token when the alert id is known', () => {
|
||||||
|
const res = parseManualToken(TOKEN, ID);
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: true,
|
||||||
|
payload: { alertId: ID, subscribeToken: TOKEN, source: 'raw' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a full payload even when an id is also supplied', () => {
|
||||||
|
const res = parseManualToken(buildQrPayload(ID, TOKEN), 'other');
|
||||||
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails a bare token with no known alert id', () => {
|
||||||
|
expect(parseManualToken(TOKEN, '')).toEqual({ ok: false, error: 'missing-alert-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails empty input', () => {
|
||||||
|
expect(parseManualToken('', ID)).toEqual({ ok: false, error: 'empty' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copy helpers', () => {
|
||||||
|
it('truncates tokens to 12 chars + ellipsis', () => {
|
||||||
|
expect(truncateToken('0123456789abcdef')).toBe('0123456789ab…');
|
||||||
|
expect(truncateToken('short')).toBe('short');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a message for every error', () => {
|
||||||
|
for (const err of ['empty', 'not-an-alarm-code', 'missing-alert-id', 'missing-token'] as const) {
|
||||||
|
expect(qrErrorMessage(err).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { parseServerTime, relativeTime, relativeSince, absoluteLocal } from '../src/lib/time';
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-10-18T20:00:00Z');
|
||||||
|
|
||||||
|
const at = (msAgo: number) => new Date(NOW - msAgo);
|
||||||
|
|
||||||
|
describe('parseServerTime', () => {
|
||||||
|
it('parses RFC3339', () => {
|
||||||
|
expect(parseServerTime('2026-10-18T19:59:00Z')?.getTime()).toBe(
|
||||||
|
Date.parse('2026-10-18T19:59:00Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for absent or garbage values', () => {
|
||||||
|
expect(parseServerTime(undefined)).toBeNull();
|
||||||
|
expect(parseServerTime(null)).toBeNull();
|
||||||
|
expect(parseServerTime('')).toBeNull();
|
||||||
|
expect(parseServerTime('not-a-date')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relativeTime', () => {
|
||||||
|
it('says "just now" under a second, including for clock skew', () => {
|
||||||
|
expect(relativeTime(at(0), NOW)).toBe('just now');
|
||||||
|
expect(relativeTime(at(500), NOW)).toBe('just now');
|
||||||
|
// A phone clock ahead of the server must never render "in 3 seconds".
|
||||||
|
expect(relativeTime(at(-5000), NOW)).toBe('just now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts seconds under a minute', () => {
|
||||||
|
expect(relativeTime(at(1000), NOW)).toBe('1s ago');
|
||||||
|
expect(relativeTime(at(2000), NOW)).toBe('2s ago');
|
||||||
|
expect(relativeTime(at(59000), NOW)).toBe('59s ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts minutes, hours and days above that', () => {
|
||||||
|
expect(relativeTime(at(60000), NOW)).toBe('1m ago');
|
||||||
|
expect(relativeTime(at(13 * 60000 + 42000), NOW)).toBe('13m ago');
|
||||||
|
expect(relativeTime(at(3600000), NOW)).toBe('1h ago');
|
||||||
|
expect(relativeTime(at(47 * 3600000), NOW)).toBe('1d ago');
|
||||||
|
expect(relativeTime(at(3 * 86400000), NOW)).toBe('3d ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relativeSince', () => {
|
||||||
|
it('formats a server string', () => {
|
||||||
|
const iso = new Date(NOW - 2000).toISOString();
|
||||||
|
expect(relativeSince(iso, NOW)).toBe('2s ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is nothing to format', () => {
|
||||||
|
expect(relativeSince(undefined, NOW)).toBeNull();
|
||||||
|
expect(relativeSince('nope', NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('absoluteLocal', () => {
|
||||||
|
it('renders zero-padded local hh:mm:ss', () => {
|
||||||
|
const d = new Date(2026, 9, 18, 8, 5, 3);
|
||||||
|
expect(absoluteLocal(d)).toBe('08:05:03');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||||
|
// root = file("../../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||||
|
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
|
||||||
|
// cliFile = file("../../node_modules/react-native/cli.js")
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
|
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
|
||||||
|
|
||||||
|
/* Bundling */
|
||||||
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
//
|
||||||
|
// The command to run when bundling. By default is 'bundle'
|
||||||
|
// bundleCommand = "ram-bundle"
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Hermes Commands */
|
||||||
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
|
|
||||||
|
/* Autolinking */
|
||||||
|
autolinkLibrariesWithApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||||
|
*/
|
||||||
|
def enableProguardInReleaseBuilds = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
|
*
|
||||||
|
* For example, to use the international variant, you can use:
|
||||||
|
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||||
|
|
||||||
|
android {
|
||||||
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace "com.dcntrlized.alarm"
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.dcntrlized.alarm"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
storeFile file('debug.keystore')
|
||||||
|
storePassword 'android'
|
||||||
|
keyAlias 'androiddebugkey'
|
||||||
|
keyPassword 'android'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
|
|
||||||
|
// Native alarm engine (AlarmForegroundService holds its own WebSocket so a
|
||||||
|
// trigger still lands when the JS runtime is backgrounded). OkHttp is what
|
||||||
|
// React Native already resolves; pinning it here makes the direct use
|
||||||
|
// explicit instead of relying on a transitive `implementation` dependency.
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
// NotificationManagerCompat / ContextCompat / ServiceInfo used by the
|
||||||
|
// module, service and player.
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
|
||||||
|
if (hermesEnabled.toBoolean()) {
|
||||||
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
} else {
|
||||||
|
implementation jscFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- Alarm delivery -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<!-- Full-screen takeover over the lock screen (Android 14+ gates this per-app). -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<!-- Exact alarms: auto-stop and any scheduled re-arm must not be deferred. -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
<!-- Battery exemption prompt (optional; the health checklist reports the result). -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!-- Foreground service: holds the armed socket while the app is backgrounded. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!-- QR scanning -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".MainApplication"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||||
|
android:supportsRtl="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Full-screen alarm takeover.
|
||||||
|
- singleTask: a second trigger reuses the same instance instead of
|
||||||
|
stacking takeovers.
|
||||||
|
- excludeFromRecents: the takeover must never appear in the recents
|
||||||
|
stack; it is a transient alarm surface, not a place you navigate to.
|
||||||
|
- showWhenLocked/turnScreenOn: appear over the lock screen and wake the
|
||||||
|
panel (also applied at runtime in AlarmActivity for older APIs).
|
||||||
|
- the dedicated theme sets windowShowWhenLocked/windowTurnScreenOn so the
|
||||||
|
window flags are right before the first frame.
|
||||||
|
-->
|
||||||
|
<activity
|
||||||
|
android:name=".AlarmActivity"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:theme="@style/AlarmTakeoverTheme"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Armed-socket holder. dataSync covers "keep a network connection open for
|
||||||
|
the user"; mediaPlayback is declared too because the same service can be
|
||||||
|
playing the alarm when a trigger foregrounds it.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".AlarmForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync|mediaPlayback" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.facebook.react.ReactActivity
|
||||||
|
import com.facebook.react.ReactActivityDelegate
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full-screen alarm takeover.
|
||||||
|
*
|
||||||
|
* Launched by a full-screen intent from the alarm notification, so it appears
|
||||||
|
* over the lock screen and turns the screen on. It reuses the main JS bundle
|
||||||
|
* and is pointed at the `LiveAlarm` route via the intent extras, so the takeover
|
||||||
|
* is the same React screen whether the app was foreground, background or dead.
|
||||||
|
*
|
||||||
|
* Manifest requirements that make this work (see AndroidManifest.xml):
|
||||||
|
* `launchMode=singleTask`, `excludeFromRecents=true`, `showWhenLocked` and
|
||||||
|
* `turnScreenOn` on the activity, and `USE_FULL_SCREEN_INTENT`.
|
||||||
|
*/
|
||||||
|
class AlarmActivity : ReactActivity() {
|
||||||
|
|
||||||
|
override fun getMainComponentName(): String = "AlarmClient"
|
||||||
|
|
||||||
|
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||||
|
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Order matters: the flags must be set before the window is shown.
|
||||||
|
showOverLockScreen()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOverLockScreen() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
// Dismiss the keyguard where the OS permits it (a secure lock screen is
|
||||||
|
// still honoured — we never try to bypass a PIN).
|
||||||
|
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && km?.isKeyguardLocked == true) {
|
||||||
|
km.requestDismissKeyguard(this, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hardware back is consumed: the takeover's only exits are in-app actions. */
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onBackPressed() {
|
||||||
|
// Intentionally swallowed — see the Live alarm takeover spec.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
AlarmPlayer.stop(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||||
|
const val EXTRA_MESSAGE = "alarm.message"
|
||||||
|
const val EXTRA_AUTO_STOP = "alarm.autoStopSeconds"
|
||||||
|
|
||||||
|
fun intent(context: Context, alertId: String, message: String, autoStopSeconds: Int): Intent =
|
||||||
|
Intent(context, AlarmActivity::class.java).apply {
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
|
||||||
|
)
|
||||||
|
putExtra(EXTRA_ALERT_ID, alertId)
|
||||||
|
putExtra(EXTRA_MESSAGE, message)
|
||||||
|
putExtra(EXTRA_AUTO_STOP, autoStopSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert id this launch is for, if any — read by the JS bridge. */
|
||||||
|
fun alertIdOf(activity: Activity?): String? =
|
||||||
|
activity?.intent?.getStringExtra(EXTRA_ALERT_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the armed socket alive while the app is not in the foreground.
|
||||||
|
*
|
||||||
|
* A foreground service with a persistent (low-importance) notification is the
|
||||||
|
* only supported way to hold a network connection across backgrounding on
|
||||||
|
* modern Android. The service owns its **own** WebSocket — it does not depend on
|
||||||
|
* the React Native JS runtime being alive — so a trigger still lands when the
|
||||||
|
* app has been backgrounded or the activity was destroyed.
|
||||||
|
*
|
||||||
|
* On a trigger frame it: plays the alarm through [AlarmPlayer] (max-volume
|
||||||
|
* `STREAM_ALARM` + vibration), posts the high-priority notification, and
|
||||||
|
* launches [AlarmActivity] as a full-screen intent so the takeover appears over
|
||||||
|
* the lock screen.
|
||||||
|
*
|
||||||
|
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`, and the
|
||||||
|
* `foregroundServiceType="dataSync"` attribute on this `<service>`.
|
||||||
|
*/
|
||||||
|
class AlarmForegroundService : Service() {
|
||||||
|
|
||||||
|
private var socket: WebSocket? = null
|
||||||
|
private var client: OkHttpClient? = null
|
||||||
|
private var alertId: String = ""
|
||||||
|
private var wsUrl: String = ""
|
||||||
|
private var stopping = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopEverything()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
alertId = intent?.getStringExtra(EXTRA_ALERT_ID) ?: alertId
|
||||||
|
wsUrl = intent?.getStringExtra(EXTRA_WS_URL) ?: wsUrl
|
||||||
|
startForegroundInternal()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Restart with the last intent if the OS kills us while armed.
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundInternal() {
|
||||||
|
AlarmNotifier.ensureChannels(this)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stop = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
Intent(this, AlarmForegroundService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val notification = android.app.Notification.Builder(this, AlarmNotifier.CHANNEL_SERVICE)
|
||||||
|
.setContentTitle("Alarm armed")
|
||||||
|
.setContentText("Listening for triggers on $alertId")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.addAction(android.app.Notification.Action.Builder(null, "Stop", stop).build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
AlarmNotifier.NOTIFICATION_ID_SERVICE,
|
||||||
|
notification,
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
if (wsUrl.isEmpty()) {
|
||||||
|
Log.w(TAG, "no ws url; service stays up but cannot listen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disconnect()
|
||||||
|
val c = OkHttpClient.Builder()
|
||||||
|
.pingInterval(25, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
client = c
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
socket = c.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleFrame(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Log.w(TAG, "socket failure: ${t.message}")
|
||||||
|
// OkHttp does not auto-reconnect; a plain retry is enough here
|
||||||
|
// because the JS client owns the visible reconnect state.
|
||||||
|
if (!stopping) {
|
||||||
|
android.os.Handler(mainLooper).postDelayed({ if (!stopping) connect() }, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (!stopping) {
|
||||||
|
android.os.Handler(mainLooper).postDelayed({ if (!stopping) connect() }, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFrame(text: String) {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(text)
|
||||||
|
if (json.optString("type") != "alarm") return
|
||||||
|
val id = json.optString("alertId", alertId)
|
||||||
|
val eventId = json.optString("eventId", "")
|
||||||
|
val action = json.optString("action", "")
|
||||||
|
val message = json.optString("message", "")
|
||||||
|
val autoStop = json.optInt("autoStopSeconds", 0)
|
||||||
|
|
||||||
|
// Acknowledge so the server can account for delivery.
|
||||||
|
if (eventId.isNotEmpty()) {
|
||||||
|
socket?.send(JSONObject().put("type", "ack").put("eventId", eventId).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
"trigger" -> {
|
||||||
|
val volumeRaised = AlarmPlayer.play(this, id, autoStop)
|
||||||
|
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
||||||
|
// Full-screen intent: the takeover covers the lock screen.
|
||||||
|
try {
|
||||||
|
startActivity(AlarmActivity.intent(this, id, message, autoStop))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "could not start AlarmActivity: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disarm" -> {
|
||||||
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelAlarm(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "bad frame: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnect() {
|
||||||
|
try {
|
||||||
|
socket?.close(1000, "client closing")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
client?.dispatcher?.executorService?.shutdown()
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopEverything() {
|
||||||
|
stopping = true
|
||||||
|
disconnect()
|
||||||
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelAlarm(this)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopping = true
|
||||||
|
disconnect()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AlarmService"
|
||||||
|
const val ACTION_STOP = "com.dcntrlized.alarm.STOP"
|
||||||
|
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||||
|
const val EXTRA_WS_URL = "alarm.wsUrl"
|
||||||
|
|
||||||
|
fun start(context: Context, alertId: String, wsUrl: String) {
|
||||||
|
val intent = Intent(context, AlarmForegroundService::class.java)
|
||||||
|
.putExtra(EXTRA_ALERT_ID, alertId)
|
||||||
|
.putExtra(EXTRA_WS_URL, wsUrl)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, AlarmForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.bridge.WritableMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS bridge for the native alarm engine. Mirrors `src/services/alarmNative.ts`
|
||||||
|
* exactly — keep the two in step.
|
||||||
|
*
|
||||||
|
* Every method resolves; none reject. The JS side treats a rejection as
|
||||||
|
* "unavailable", and a resolve with `ok:false` carries the reason so the UI can
|
||||||
|
* explain which rung of the escalation ladder actually fired.
|
||||||
|
*/
|
||||||
|
class AlarmModule(private val reactContext: ReactApplicationContext) :
|
||||||
|
ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
override fun getName(): String = NAME
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun playAlarm(alertId: String, message: String, autoStopSeconds: Double, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val volumeRaised = AlarmPlayer.play(reactContext, alertId, autoStopSeconds.toInt())
|
||||||
|
promise.resolve(result(ok = true, volumeRaised = volumeRaised))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(result(ok = false, reason = e.message ?: "play-failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun stopAlarm(promise: Promise) {
|
||||||
|
try {
|
||||||
|
AlarmPlayer.stop(reactContext)
|
||||||
|
AlarmNotifier.cancelAlarm(reactContext)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(result(ok = false, reason = e.message ?: "stop-failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun startService(alertId: String, subscribeToken: String, wsUrl: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(reactContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
// A foreground service without its notification is killed by the
|
||||||
|
// OS; refuse loudly instead of starting something that dies.
|
||||||
|
promise.resolve(result(ok = false, reason = "notifications-not-granted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AlarmForegroundService.start(reactContext, alertId, wsUrl)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(result(ok = false, reason = e.message ?: "service-failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun stopService(promise: Promise) {
|
||||||
|
try {
|
||||||
|
AlarmForegroundService.stop(reactContext)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(result(ok = false, reason = e.message ?: "service-failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun getPermissions(promise: Promise) {
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `name` is one of `notifications` | `fullScreenIntent` | `exactAlarm` |
|
||||||
|
* `battery`. Android permission prompts must run on an activity, so the
|
||||||
|
* module stores the pending promise and `AlarmPermissionActivity` (or the
|
||||||
|
* current activity's result callback) resolves it.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun requestPermission(name: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
when (name) {
|
||||||
|
"notifications" -> requestNotifications(promise)
|
||||||
|
"fullScreenIntent" -> {
|
||||||
|
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
"exactAlarm" -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
openSettings(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||||
|
}
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
"battery" -> {
|
||||||
|
requestBatteryExemption()
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
else -> promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot read of the alert a native full-screen launch is for.
|
||||||
|
*
|
||||||
|
* [AlarmActivity] is started from the foreground service with the alert id
|
||||||
|
* in its intent, so the JS takeover can render the right alert even when
|
||||||
|
* the process was cold-started. The extras are consumed here so a later
|
||||||
|
* remount does not re-open a stale takeover.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun getLaunchAlert(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val activity = reactContext.currentActivity
|
||||||
|
val alertId = AlarmActivity.alertIdOf(activity)
|
||||||
|
if (activity == null || alertId.isNullOrEmpty()) {
|
||||||
|
promise.resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = activity.intent
|
||||||
|
val map = Arguments.createMap()
|
||||||
|
map.putString("alertId", alertId)
|
||||||
|
map.putString("message", intent?.getStringExtra(AlarmActivity.EXTRA_MESSAGE) ?: "")
|
||||||
|
map.putInt(
|
||||||
|
"autoStopSeconds",
|
||||||
|
intent?.getIntExtra(AlarmActivity.EXTRA_AUTO_STOP, 0) ?: 0
|
||||||
|
)
|
||||||
|
intent?.removeExtra(AlarmActivity.EXTRA_ALERT_ID)
|
||||||
|
promise.resolve(map)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun isIgnoringBatteryOptimizations(promise: Promise) {
|
||||||
|
promise.resolve(isBatteryUnrestricted())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun openFullScreenIntentSettings(promise: Promise) {
|
||||||
|
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system volume UI. Used by the takeover's "Raise volume" recovery
|
||||||
|
* action when the OS refused to let us take the alarm stream — a warning the
|
||||||
|
* user cannot act on is just noise.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun openSoundSettings(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_SOUND_SETTINGS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
reactContext.startActivity(intent)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(result(ok = false, reason = e.message ?: "no-sound-settings"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens this app's Android settings page (used by the camera-denied state). */
|
||||||
|
@ReactMethod
|
||||||
|
fun openAppSettings(promise: Promise) {
|
||||||
|
openSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun openExactAlarmSettings(promise: Promise) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
openSettings(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||||
|
}
|
||||||
|
promise.resolve(result(ok = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun requestNotifications(promise: Promise) {
|
||||||
|
val activity = reactContext.currentActivity
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || activity == null) {
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
REQ_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
// The system dialog is asynchronous; report the current state and let
|
||||||
|
// the JS side re-read it when the app returns to the foreground.
|
||||||
|
promise.resolve(permissionMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestBatteryExemption() {
|
||||||
|
if (isBatteryUnrestricted()) return
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
|
.setData(Uri.parse("package:${reactContext.packageName}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
reactContext.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
openSettings(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSettings(action: String) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(action)
|
||||||
|
.setData(Uri.parse("package:${reactContext.packageName}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
reactContext.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Some OEMs do not implement the per-app action; fall back to the
|
||||||
|
// app details screen rather than crashing.
|
||||||
|
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
.setData(Uri.parse("package:${reactContext.packageName}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
reactContext.startActivity(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBatteryUnrestricted(): Boolean {
|
||||||
|
val pm = reactContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false
|
||||||
|
return pm.isIgnoringBatteryOptimizations(reactContext.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasNotifications(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
return NotificationManagerCompat.from(reactContext).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
return ContextCompat.checkSelfPermission(reactContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED &&
|
||||||
|
NotificationManagerCompat.from(reactContext).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasFullScreenIntent(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
|
||||||
|
val nm = reactContext.getSystemService(android.app.NotificationManager::class.java)
|
||||||
|
return nm?.canUseFullScreenIntent() ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasExactAlarm(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||||
|
val am = reactContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
|
return am.canScheduleExactAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `canForceVolume` is our own honest signal: it is true only when the alarm
|
||||||
|
* stream is not silenced by the ringer mode. The JS layer uses it to decide
|
||||||
|
* whether to promise max volume or to lean on the notification fallback.
|
||||||
|
*/
|
||||||
|
private fun canForceVolume(): Boolean {
|
||||||
|
val am = reactContext.getSystemService(Context.AUDIO_SERVICE) as? android.media.AudioManager
|
||||||
|
?: return false
|
||||||
|
return am.getStreamVolume(android.media.AudioManager.STREAM_ALARM) > 0 ||
|
||||||
|
am.ringerMode != android.media.AudioManager.RINGER_MODE_SILENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionMap(): WritableMap {
|
||||||
|
val map = Arguments.createMap()
|
||||||
|
map.putBoolean("notifications", hasNotifications())
|
||||||
|
map.putBoolean("fullScreenIntent", hasFullScreenIntent())
|
||||||
|
map.putBoolean("exactAlarm", hasExactAlarm())
|
||||||
|
map.putBoolean("batteryUnrestricted", isBatteryUnrestricted())
|
||||||
|
map.putBoolean("canForceVolume", canForceVolume())
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun result(
|
||||||
|
ok: Boolean,
|
||||||
|
volumeRaised: Boolean? = null,
|
||||||
|
reason: String? = null
|
||||||
|
): WritableMap {
|
||||||
|
val map = Arguments.createMap()
|
||||||
|
map.putBoolean("ok", ok)
|
||||||
|
volumeRaised?.let { map.putBoolean("volumeRaised", it) }
|
||||||
|
reason?.let { map.putString("reason", it) }
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME = "AlarmModule"
|
||||||
|
private const val REQ_NOTIFICATIONS = 9101
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers [AlarmModule]. This package is hand-written (not autolinked) because
|
||||||
|
* it lives in the app module rather than a separate library, so it is added
|
||||||
|
* explicitly in `MainApplication.kt`.
|
||||||
|
*/
|
||||||
|
class AlarmPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||||
|
listOf(AlarmModule(reactContext))
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max-volume alarm playback.
|
||||||
|
*
|
||||||
|
* Escalation ladder, in order:
|
||||||
|
* 1. `STREAM_ALARM` at `setVolume(1f)` — the only stream that ignores the
|
||||||
|
* media volume and, on most devices, keeps ringing in silent mode;
|
||||||
|
* 2. a `Vibrator` waveform alongside the audio, so a muted ringer still
|
||||||
|
* reaches the user;
|
||||||
|
* 3. (see [AlarmNotifier]) a high-priority notification with a "Raise your
|
||||||
|
* volume" action when the OS refuses the alarm stream entirely.
|
||||||
|
*
|
||||||
|
* We never report success unless the alarm stream was actually raised, so the
|
||||||
|
* UI can tell the truth about which rung of the ladder carried the alarm.
|
||||||
|
*/
|
||||||
|
object AlarmPlayer {
|
||||||
|
private const val TAG = "AlarmPlayer"
|
||||||
|
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var vibrator: Vibrator? = null
|
||||||
|
|
||||||
|
/** Vibration pattern: wait 0, buzz 700, rest 400, repeated from index 1. */
|
||||||
|
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun play(context: Context, alertId: String, autoStopSeconds: Int): Boolean {
|
||||||
|
stop(context)
|
||||||
|
val volumeRaised = raiseAlarmStream(context)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
||||||
|
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
|
||||||
|
val mp = MediaPlayer()
|
||||||
|
mp.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
mp.setDataSource(context, uri)
|
||||||
|
mp.isLooping = true
|
||||||
|
mp.setVolume(1f, 1f)
|
||||||
|
mp.prepare()
|
||||||
|
mp.start()
|
||||||
|
player = mp
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "alarm audio failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
startVibration(context)
|
||||||
|
AlarmNotifier.showAlarmNotification(context, alertId, volumeRaised, autoStopSeconds)
|
||||||
|
return volumeRaised
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop(context: Context) {
|
||||||
|
try {
|
||||||
|
player?.let {
|
||||||
|
if (it.isPlaying) it.stop()
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "player stop failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
vibrator?.cancel()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "vibrator cancel failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
vibrator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raise the alarm stream and report whether the OS let us.
|
||||||
|
*
|
||||||
|
* `setStreamVolume` is a no-op on some OEM builds and throws
|
||||||
|
* `SecurityException` under DND. Both are reported as `false` rather than
|
||||||
|
* swallowed, because the notification fallback depends on knowing.
|
||||||
|
*/
|
||||||
|
private fun raiseAlarmStream(context: Context): Boolean {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false
|
||||||
|
return try {
|
||||||
|
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
|
||||||
|
am.setStreamVolume(AudioManager.STREAM_ALARM, max, 0)
|
||||||
|
am.getStreamVolume(AudioManager.STREAM_ALARM) >= max
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "could not raise STREAM_ALARM: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVibration(context: Context) {
|
||||||
|
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
if (v == null || !v.hasVibrator()) return
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
v.vibrate(VibrationEffect.createWaveform(VIBRATION_PATTERN, 1))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
v.vibrate(VIBRATION_PATTERN, 1)
|
||||||
|
}
|
||||||
|
vibrator = v
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "vibration failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notification channels + the alarm notification, including the volume fallback. */
|
||||||
|
object AlarmNotifier {
|
||||||
|
const val CHANNEL_ALARM = "alarm_trigger"
|
||||||
|
const val CHANNEL_SERVICE = "alarm_armed"
|
||||||
|
|
||||||
|
fun ensureChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
|
||||||
|
val alarm = NotificationChannel(
|
||||||
|
CHANNEL_ALARM,
|
||||||
|
"Alarm triggers",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Fires at full volume when an alert is triggered"
|
||||||
|
enableVibration(true)
|
||||||
|
setShowBadge(true)
|
||||||
|
// The alarm is the payload: bypass DND where the user allows it.
|
||||||
|
setBypassDnd(true)
|
||||||
|
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(alarm)
|
||||||
|
|
||||||
|
val service = NotificationChannel(
|
||||||
|
CHANNEL_SERVICE,
|
||||||
|
"Armed alert",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Keeps the connection to the alarm service open while armed"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-priority alarm notification. When `volumeRaised` is false the
|
||||||
|
* notification carries a "Raise your volume" action, because the OS refused
|
||||||
|
* the forced alarm stream and the user has to do it.
|
||||||
|
*/
|
||||||
|
fun showAlarmNotification(
|
||||||
|
context: Context,
|
||||||
|
alertId: String,
|
||||||
|
volumeRaised: Boolean,
|
||||||
|
autoStopSeconds: Int
|
||||||
|
) {
|
||||||
|
ensureChannels(context)
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
|
||||||
|
val open = android.app.PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
alertId.hashCode(),
|
||||||
|
AlarmActivity.intent(context, alertId, "", autoStopSeconds),
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = android.app.Notification.Builder(context, CHANNEL_ALARM)
|
||||||
|
.setContentTitle("Alarm")
|
||||||
|
.setContentText(if (volumeRaised) "Alarm ringing" else "Raise your volume to hear the alarm")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||||
|
.setCategory(android.app.Notification.CATEGORY_ALARM)
|
||||||
|
.setPriority(android.app.Notification.PRIORITY_MAX)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setFullScreenIntent(open, true)
|
||||||
|
|
||||||
|
if (!volumeRaised) {
|
||||||
|
val raise = android.app.PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
alertId.hashCode() + 1,
|
||||||
|
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
builder.addAction(
|
||||||
|
android.app.Notification.Action.Builder(
|
||||||
|
null,
|
||||||
|
"Raise your volume",
|
||||||
|
raise
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
nm.notify(NOTIFICATION_ID_ALARM, builder.build())
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAlarm(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
nm.cancel(NOTIFICATION_ID_ALARM)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val NOTIFICATION_ID_ALARM = 4711
|
||||||
|
const val NOTIFICATION_ID_SERVICE = 4712
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import com.facebook.react.ReactActivity
|
||||||
|
import com.facebook.react.ReactActivityDelegate
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
|
class MainActivity : ReactActivity() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
|
* rendering of the component.
|
||||||
|
*/
|
||||||
|
override fun getMainComponentName(): String = "AlarmClient"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||||
|
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||||
|
*/
|
||||||
|
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||||
|
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.facebook.react.PackageList
|
||||||
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactHost
|
||||||
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
|
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||||
|
|
||||||
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
|
override val reactHost: ReactHost by lazy {
|
||||||
|
getDefaultReactHost(
|
||||||
|
context = applicationContext,
|
||||||
|
packageList =
|
||||||
|
PackageList(this).packages.apply {
|
||||||
|
// The alarm engine lives in the app module (not a separate library),
|
||||||
|
// so it is registered by hand rather than autolinked.
|
||||||
|
add(AlarmPackage())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
loadReactNative(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||||
|
>
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- DESIGN.md takeover flat fallback (`takeoverFlat #A82A15`). -->
|
||||||
|
<color name="alarm_takeover_flat">#A82A15</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Alarm</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Full-screen alarm takeover. Flat takeover colour so the first frame is the
|
||||||
|
right colour before React mounts. The lock-screen/turn-screen behaviour
|
||||||
|
comes from the activity's `showWhenLocked`/`turnScreenOn` manifest
|
||||||
|
attributes plus the runtime window flags in AlarmActivity — there is no
|
||||||
|
theme attribute for them (`android:windowShowWhenLocked` does not exist).
|
||||||
|
-->
|
||||||
|
<style name="AlarmTakeoverTheme" parent="AppTheme">
|
||||||
|
<item name="android:windowBackground">@color/alarm_takeover_flat</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowActionBar">false</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
buildscript {
|
||||||
|
ext {
|
||||||
|
buildToolsVersion = "36.0.0"
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
ndkVersion = "27.1.12297006"
|
||||||
|
kotlinVersion = "2.1.20"
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle")
|
||||||
|
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||||
|
org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
kotlin.daemon.jvmargs=-Xmx512m
|
||||||
|
|
||||||
|
# Dev server address baked into the debug app. 127.0.0.1 so a USB device
|
||||||
|
# reaches metro via `adb reverse`. Port 8089, not the default 8081 (taken by
|
||||||
|
# keepnote-debug) and NOT 8088: 8088 is the Alarm service's documented host
|
||||||
|
# port, and `adb reverse tcp:8088` would collide with it in direct mode.
|
||||||
|
# Per-client Metro ports: keepnote=8082, hub=8084, calendar=8086, alarm=8089.
|
||||||
|
reactNativeDevServerIp=127.0.0.1
|
||||||
|
reactNativeDevServerPort=8089
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Use this property to specify which architecture you want to build.
|
||||||
|
# You can also override it from the CLI using
|
||||||
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
|
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||||
|
|
||||||
|
# Use this property to enable support to the new architecture.
|
||||||
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
# your application. You should enable this flag either if you want
|
||||||
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
|
# are providing them.
|
||||||
|
newArchEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
|
# If set to false, you will be using JSC instead.
|
||||||
|
hermesEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable edge-to-edge display support.
|
||||||
|
# This allows your app to draw behind system bars for an immersive UI.
|
||||||
|
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||||
|
edgeToEdgeEnabled=false
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
@REM
|
||||||
|
@REM This source code is licensed under the MIT license found in the
|
||||||
|
@REM LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
|
||||||
|
plugins { id("com.facebook.react.settings") }
|
||||||
|
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
|
||||||
|
rootProject.name = 'com.dcntrlized.alarm'
|
||||||
|
include ':app'
|
||||||
|
includeBuild('../node_modules/@react-native/gradle-plugin')
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ['module:@react-native/babel-preset'],
|
||||||
|
plugins: ['react-native-worklets/plugin'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'uniwind';
|
||||||
|
@import 'heroui-native/styles';
|
||||||
|
|
||||||
|
@source './node_modules/heroui-native/lib';
|
||||||
|
@source './App.tsx';
|
||||||
|
@source './src';
|
||||||
|
|
||||||
|
@layer theme {
|
||||||
|
:root {
|
||||||
|
@variant light {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@variant dark {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './global.css';
|
||||||
|
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
|
|
||||||
|
import { AppRegistry } from 'react-native';
|
||||||
|
import App from './App';
|
||||||
|
import { name as appName } from './app.json';
|
||||||
|
|
||||||
|
AppRegistry.registerComponent(appName, () => App);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# This `.xcode.env` file is versioned and is used to source the environment
|
||||||
|
# used when running script phases inside Xcode.
|
||||||
|
# To customize your local environment, you can create an `.xcode.env.local`
|
||||||
|
# file that is not versioned.
|
||||||
|
|
||||||
|
# NODE_BINARY variable contains the PATH to the node executable.
|
||||||
|
#
|
||||||
|
# Customize the NODE_BINARY variable here.
|
||||||
|
# For example, to use nvm with brew, add the following line
|
||||||
|
# . "$(brew --prefix nvm)/nvm.sh" --no-use
|
||||||
|
export NODE_BINARY=$(command -v node)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
0C80B921A6F3F58F76C31292 /* libPods-CalendarClient.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-CalendarClient.a */; };
|
||||||
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
|
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||||
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
13B07F961A680F5B00A75B9A /* CalendarClient.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CalendarClient.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = CalendarClient/Images.xcassets; sourceTree = "<group>"; };
|
||||||
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = CalendarClient/Info.plist; sourceTree = "<group>"; };
|
||||||
|
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = CalendarClient/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
3B4392A12AC88292D35C810B /* Pods-CalendarClient.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CalendarClient.debug.xcconfig"; path = "Target Support Files/Pods-CalendarClient/Pods-CalendarClient.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
5709B34CF0A7D63546082F79 /* Pods-CalendarClient.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CalendarClient.release.xcconfig"; path = "Target Support Files/Pods-CalendarClient/Pods-CalendarClient.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
5DCACB8F33CDC322A6C60F78 /* libPods-CalendarClient.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-CalendarClient.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = CalendarClient/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = CalendarClient/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0C80B921A6F3F58F76C31292 /* libPods-CalendarClient.a in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
13B07FAE1A68108700A75B9A /* CalendarClient */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
|
761780EC2CA45674006654EE /* AppDelegate.swift */,
|
||||||
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
|
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
|
||||||
|
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
|
||||||
|
);
|
||||||
|
name = CalendarClient;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
|
5DCACB8F33CDC322A6C60F78 /* libPods-CalendarClient.a */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
name = Libraries;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B07FAE1A68108700A75B9A /* CalendarClient */,
|
||||||
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
|
83CBBA001A601CBA00E9B192 /* Products */,
|
||||||
|
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||||
|
BBD78D7AC51CEA395F1C20DB /* Pods */,
|
||||||
|
);
|
||||||
|
indentWidth = 2;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
tabWidth = 2;
|
||||||
|
usesTabs = 0;
|
||||||
|
};
|
||||||
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B07F961A680F5B00A75B9A /* CalendarClient.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B4392A12AC88292D35C810B /* Pods-CalendarClient.debug.xcconfig */,
|
||||||
|
5709B34CF0A7D63546082F79 /* Pods-CalendarClient.release.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
13B07F861A680F5B00A75B9A /* CalendarClient */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "CalendarClient" */;
|
||||||
|
buildPhases = (
|
||||||
|
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
|
||||||
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
|
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
|
||||||
|
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = CalendarClient;
|
||||||
|
productName = CalendarClient;
|
||||||
|
productReference = 13B07F961A680F5B00A75B9A /* CalendarClient.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastUpgradeCheck = 1210;
|
||||||
|
TargetAttributes = {
|
||||||
|
13B07F861A680F5B00A75B9A = {
|
||||||
|
LastSwiftMigration = 1120;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "CalendarClient" */;
|
||||||
|
compatibilityVersion = "Xcode 12.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
13B07F861A680F5B00A75B9A /* CalendarClient */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||||
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/.xcode.env.local",
|
||||||
|
"$(SRCROOT)/.xcode.env",
|
||||||
|
);
|
||||||
|
name = "Bundle React Native code and images";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n";
|
||||||
|
};
|
||||||
|
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-CalendarClient-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-CalendarClient/Pods-CalendarClient-resources.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
13B07F871A680F5B00A75B9A /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-CalendarClient.debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = CalendarClient/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.dcntrlized.calendar";
|
||||||
|
PRODUCT_NAME = CalendarClient;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-CalendarClient.release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
INFOPLIST_FILE = CalendarClient/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.dcntrlized.calendar";
|
||||||
|
PRODUCT_NAME = CalendarClient;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = 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_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_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
|
||||||
|
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 = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
/usr/lib/swift,
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = (
|
||||||
|
"\"$(SDKROOT)/usr/lib/swift\"",
|
||||||
|
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
|
||||||
|
"\"$(inherited)\"",
|
||||||
|
);
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
OTHER_CPLUSPLUSFLAGS = (
|
||||||
|
"$(OTHER_CFLAGS)",
|
||||||
|
"-DFOLLY_NO_CONFIG",
|
||||||
|
"-DFOLLY_MOBILE=1",
|
||||||
|
"-DFOLLY_USE_LIBCPP=1",
|
||||||
|
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||||
|
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||||
|
);
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
83CBBA211A601CBA00E9B192 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = 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_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_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = YES;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
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 = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
/usr/lib/swift,
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = (
|
||||||
|
"\"$(SDKROOT)/usr/lib/swift\"",
|
||||||
|
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
|
||||||
|
"\"$(inherited)\"",
|
||||||
|
);
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
OTHER_CPLUSPLUSFLAGS = (
|
||||||
|
"$(OTHER_CFLAGS)",
|
||||||
|
"-DFOLLY_NO_CONFIG",
|
||||||
|
"-DFOLLY_MOBILE=1",
|
||||||
|
"-DFOLLY_USE_LIBCPP=1",
|
||||||
|
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||||
|
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||||
|
);
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "CalendarClient" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
13B07F941A680F5B00A75B9A /* Debug */,
|
||||||
|
13B07F951A680F5B00A75B9A /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "CalendarClient" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||||
|
83CBBA211A601CBA00E9B192 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1210"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "CalendarClient.app"
|
||||||
|
BlueprintName = "CalendarClient"
|
||||||
|
ReferencedContainer = "container:CalendarClient.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||||
|
BuildableName = "CalendarClientTests.xctest"
|
||||||
|
BlueprintName = "CalendarClientTests"
|
||||||
|
ReferencedContainer = "container:CalendarClient.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "CalendarClient.app"
|
||||||
|
BlueprintName = "CalendarClient"
|
||||||
|
ReferencedContainer = "container:CalendarClient.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "CalendarClient.app"
|
||||||
|
BlueprintName = "CalendarClient"
|
||||||
|
ReferencedContainer = "container:CalendarClient.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import UIKit
|
||||||
|
import React
|
||||||
|
import React_RCTAppDelegate
|
||||||
|
import ReactAppDependencyProvider
|
||||||
|
|
||||||
|
@main
|
||||||
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
var reactNativeDelegate: ReactNativeDelegate?
|
||||||
|
var reactNativeFactory: RCTReactNativeFactory?
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
let delegate = ReactNativeDelegate()
|
||||||
|
let factory = RCTReactNativeFactory(delegate: delegate)
|
||||||
|
delegate.dependencyProvider = RCTAppDependencyProvider()
|
||||||
|
|
||||||
|
reactNativeDelegate = delegate
|
||||||
|
reactNativeFactory = factory
|
||||||
|
|
||||||
|
window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
|
||||||
|
factory.startReactNative(
|
||||||
|
withModuleName: "CalendarClient",
|
||||||
|
in: window,
|
||||||
|
launchOptions: launchOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
||||||
|
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
||||||
|
self.bundleURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bundleURL() -> URL? {
|
||||||
|
#if DEBUG
|
||||||
|
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
||||||
|
#else
|
||||||
|
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?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>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>CalendarClient</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string></string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
</array>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="CalendarClient" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
|
||||||
|
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
|
||||||
|
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
|
||||||
|
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
|
||||||
|
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
|
||||||
|
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
|
||||||
|
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
|
||||||
|
</constraints>
|
||||||
|
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="52.173913043478265" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Resolve react_native_pods.rb with node to allow for hoisting
|
||||||
|
require Pod::Executable.execute_command('node', ['-p',
|
||||||
|
'require.resolve(
|
||||||
|
"react-native/scripts/react_native_pods.rb",
|
||||||
|
{paths: [process.argv[1]]},
|
||||||
|
)', __dir__]).strip
|
||||||
|
|
||||||
|
platform :ios, min_ios_version_supported
|
||||||
|
prepare_react_native_project!
|
||||||
|
|
||||||
|
linkage = ENV['USE_FRAMEWORKS']
|
||||||
|
if linkage != nil
|
||||||
|
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
|
||||||
|
use_frameworks! :linkage => linkage.to_sym
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'CalendarClient' do
|
||||||
|
config = use_native_modules!
|
||||||
|
|
||||||
|
use_react_native!(
|
||||||
|
:path => config[:reactNativePath],
|
||||||
|
# An absolute path to your application root.
|
||||||
|
:app_path => "#{Pod::Config.instance.installation_root}/.."
|
||||||
|
)
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
react_native_post_install(
|
||||||
|
installer,
|
||||||
|
config[:reactNativePath],
|
||||||
|
:mac_catalyst_enabled => false,
|
||||||
|
# :ccache_enabled => true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: '@react-native/jest-preset',
|
||||||
|
// Only this repo's own tests: `.bunhome` (the workspace-local bun cache used
|
||||||
|
// because this sandbox cannot write ~/.bun) contains third-party packages with
|
||||||
|
// their own test files, which jest would otherwise pick up.
|
||||||
|
roots: ['<rootDir>/__tests__'],
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '/.bunhome/', '/.gradle-home/', '/android/'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { getDefaultConfig } = require('@react-native/metro-config');
|
||||||
|
const { withUniwindConfig } = require('uniwind/metro');
|
||||||
|
|
||||||
|
const config = withUniwindConfig(getDefaultConfig(__dirname), {
|
||||||
|
cssEntryFile: './global.css',
|
||||||
|
dtsFile: './src/uniwind-types.d.ts',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep Metro's file watcher out of the transient Gradle/Xcode build output
|
||||||
|
// dirs. Without watchman, Metro's fallback watcher chokes (ENOENT) when these
|
||||||
|
// directories disappear mid-build.
|
||||||
|
config.resolver.blockList = [
|
||||||
|
/\/android\/app\/build\/.*/,
|
||||||
|
/\/android\/build\/.*/,
|
||||||
|
/\/android\/\.gradle\/.*/,
|
||||||
|
/\/build\/generated\/.*/,
|
||||||
|
/\/ios\/build\/.*/,
|
||||||
|
/\/ios\/Pods\/.*/,
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "com.dcntrlized.alarm",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"android": "bash scripts/android.sh",
|
||||||
|
"ios": "react-native run-ios",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"start": "react-native start --port 8089",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||||
|
"@react-native-clipboard/clipboard": "1.16.3",
|
||||||
|
"@react-native-masked-view/masked-view": "0.3.2",
|
||||||
|
"@react-native/new-app-screen": "0.86.2",
|
||||||
|
"@react-navigation/native": "^7",
|
||||||
|
"@react-navigation/native-stack": "^7",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"heroui-native": "^1.0.8",
|
||||||
|
"lucide-react-native": "^1.31.0",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.86.2",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-haptic-feedback": "2.3.3",
|
||||||
|
"react-native-nitro-image": "^0.15.2",
|
||||||
|
"react-native-nitro-modules": "^0.37.1",
|
||||||
|
"react-native-reanimated": "4.5.0",
|
||||||
|
"react-native-safe-area-context": "~5.8.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-svg": "15.15.5",
|
||||||
|
"react-native-vision-camera": "5.2.3",
|
||||||
|
"react-native-worklets": "0.10.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwind-variants": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"uniwind": "^1.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/preset-env": "^7.25.3",
|
||||||
|
"@babel/runtime": "^7.25.0",
|
||||||
|
"@react-native-community/cli": "20.1.0",
|
||||||
|
"@react-native-community/cli-platform-android": "20.1.0",
|
||||||
|
"@react-native-community/cli-platform-ios": "20.1.0",
|
||||||
|
"@react-native/babel-preset": "0.86.2",
|
||||||
|
"@react-native/eslint-config": "0.86.2",
|
||||||
|
"@react-native/jest-preset": "0.86.2",
|
||||||
|
"@react-native/metro-config": "0.86.2",
|
||||||
|
"@react-native/typescript-config": "0.86.2",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"eslint": "^8.19.0",
|
||||||
|
"jest": "^29.6.3",
|
||||||
|
"prettier": "2.8.8",
|
||||||
|
"react-test-renderer": "19.2.3",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22.11.0"
|
||||||
|
},
|
||||||
|
"packageManager": "[email protected]"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dev launcher for the Alarm client.
|
||||||
|
#
|
||||||
|
# Expects metro already running on 127.0.0.1:8089 (start it with `bun start`
|
||||||
|
# in another terminal). Reverses the adb tunnels so the device reaches the
|
||||||
|
# dev machine via 127.0.0.1, then builds, installs and launches the app:
|
||||||
|
# - 8080 Maestro (dev default)
|
||||||
|
# - 8088 Alarm direct mode (bypasses Maestro) — the service's host port
|
||||||
|
# - 8089 the Metro packager (this client's port)
|
||||||
|
# Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>, so Metro
|
||||||
|
# and the service must keep distinct numbers; 8088 belongs to the service.
|
||||||
|
#
|
||||||
|
# If your phone is on the same LAN instead, turn "Use adb reverse" off in
|
||||||
|
# Settings and it will fall back to the LAN host baked into src/services/connection.ts.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for port in 8080 8088 8089; do
|
||||||
|
adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -sf http://127.0.0.1:8089/status >/dev/null 2>&1; then
|
||||||
|
echo "WARNING: metro is not running on :8089. Start it first with: bun start"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec bunx react-native run-android --port 8089 --no-packager
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { radiusMd } from '../theme/tokens';
|
||||||
|
|
||||||
|
export interface QrCanvasProps {
|
||||||
|
/** The exact string encoded in the QR (see `lib/qrPayload.buildQrPayload`). */
|
||||||
|
value: string;
|
||||||
|
/** Tile edge in points. DESIGN.md pins 240x240 on the QR display screen. */
|
||||||
|
size?: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** Accessibility label override; defaults to a generic alarm-code label. */
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR rendering with no native QR dependency: `qrcode` (pure JS) produces the
|
||||||
|
* module matrix, and `react-native-svg` draws it as one path on a light tile.
|
||||||
|
* The tile is always white with dark modules — a QR on a dark surface is not
|
||||||
|
* reliably scannable, so the tile is the one place in the app that is not
|
||||||
|
* token-dark (DESIGN.md §7.4).
|
||||||
|
*/
|
||||||
|
export function QrCanvas({
|
||||||
|
value,
|
||||||
|
size = 240,
|
||||||
|
style,
|
||||||
|
accessibilityLabel = 'Alert QR code',
|
||||||
|
}: QrCanvasProps) {
|
||||||
|
const path = useMemo(() => {
|
||||||
|
const qr = QRCode.create(value, { errorCorrectionLevel: 'M' });
|
||||||
|
const count: number = qr.modules.size;
|
||||||
|
const data = qr.modules.data as unknown as Uint8Array;
|
||||||
|
// 4 modules of quiet zone on each side, per the QR spec — the quiet zone is
|
||||||
|
// part of the encoded geometry so the drawn tile stays exactly `size`.
|
||||||
|
const quiet = 4;
|
||||||
|
const total = count + quiet * 2;
|
||||||
|
const unit = size / total;
|
||||||
|
const u = unit.toFixed(2);
|
||||||
|
let d = '';
|
||||||
|
for (let y = 0; y < count; y += 1) {
|
||||||
|
for (let x = 0; x < count; x += 1) {
|
||||||
|
if (!data[y * count + x]) continue;
|
||||||
|
const px = ((x + quiet) * unit).toFixed(2);
|
||||||
|
const py = ((y + quiet) * unit).toFixed(2);
|
||||||
|
d += `M${px} ${py}h${u}v${u}h-${u}z`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}, [value, size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
accessible
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
style={[
|
||||||
|
{ width: size, height: size, backgroundColor: '#FFFFFF', borderRadius: radiusMd },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<Rect x={0} y={0} width={size} height={size} fill="#FFFFFF" />
|
||||||
|
<Path d={path} fill="#0B0D10" />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
} from 'react-native';
|
||||||
|
import { OctagonX, Zap } from 'lucide-react-native';
|
||||||
|
import { AppText, Button } from './ui';
|
||||||
|
import {
|
||||||
|
minTapTarget,
|
||||||
|
radiusLg,
|
||||||
|
radiusXl,
|
||||||
|
space2,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
stateAlarm,
|
||||||
|
stateAlarmDeep,
|
||||||
|
stateAlarmInk,
|
||||||
|
surface,
|
||||||
|
text,
|
||||||
|
textSecondary,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
typeBody,
|
||||||
|
typeTitle,
|
||||||
|
triggerHoldMs,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort `impactHeavy`. The spec fires it exactly once per trigger attempt
|
||||||
|
* at hold completion; if the haptics module is unavailable the interaction
|
||||||
|
* still completes (haptics are never load-bearing).
|
||||||
|
*/
|
||||||
|
function impactHeavy(): void {
|
||||||
|
try {
|
||||||
|
// Lazy require so a missing native module cannot break the screen.
|
||||||
|
const Haptics = require('react-native-haptic-feedback');
|
||||||
|
Haptics.trigger('impactHeavy', { enableVibrateFallback: true, ignoreAndroidSystemSettings: false });
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerPadProps {
|
||||||
|
/** Fired after the 1200ms hold completes and the user confirms. */
|
||||||
|
onTrigger: () => void;
|
||||||
|
/** Disarm is a single tap, no confirm, never rate-limited. */
|
||||||
|
onDisarm: () => void;
|
||||||
|
/**
|
||||||
|
* Whether the alarm is currently ringing. There is **no** arm/enablement gate:
|
||||||
|
* the service accepts a trigger from `idle` and `disarmed` too (no
|
||||||
|
* `armAlert` action exists), so the pad is always enabled and merely shows
|
||||||
|
* what the last event was.
|
||||||
|
*/
|
||||||
|
ringing?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Why the pad is disabled — always rendered, so the reason is never a mystery. */
|
||||||
|
disabledReason?: string;
|
||||||
|
/** Number of devices the confirmation sheet will name. */
|
||||||
|
deviceCount?: number;
|
||||||
|
/** Confirmation copy; kept here so both screens agree verbatim. */
|
||||||
|
confirmTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-step trigger control (DESIGN.md §7.3):
|
||||||
|
* 1. press-and-hold 1200ms — a `stateAlarm` fill sweeps left→right across the
|
||||||
|
* pad's real width and the surface scales to 0.98; releasing early cancels
|
||||||
|
* with no side effect;
|
||||||
|
* 2. on completion, `impactHeavy` fires once and a **modal confirmation
|
||||||
|
* sheet** asks `Trigger the alarm on N devices?`, with the destructive
|
||||||
|
* action stacked and dominant.
|
||||||
|
*
|
||||||
|
* Disarm is deliberately NOT symmetric: one tap, no confirm, never disabled.
|
||||||
|
* Both controls are always present in one stack — the trigger is only visually
|
||||||
|
* disabled while the alarm is already ringing, so the pattern never moves under
|
||||||
|
* the user's thumb.
|
||||||
|
*/
|
||||||
|
export function TriggerPad({
|
||||||
|
onTrigger,
|
||||||
|
onDisarm,
|
||||||
|
ringing = false,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
deviceCount = 0,
|
||||||
|
confirmTitle,
|
||||||
|
}: TriggerPadProps) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
const fill = useRef(new Animated.Value(0)).current;
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const animRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||||
|
|
||||||
|
const cancelHold = useCallback(() => {
|
||||||
|
animRef.current?.stop();
|
||||||
|
animRef.current = null;
|
||||||
|
Animated.timing(fill, { toValue: 0, duration: 140, useNativeDriver: true }).start();
|
||||||
|
Animated.timing(scale, { toValue: 1, duration: 140, useNativeDriver: true }).start();
|
||||||
|
}, [fill, scale]);
|
||||||
|
|
||||||
|
useEffect(() => cancelHold, [cancelHold]);
|
||||||
|
|
||||||
|
const startHold = useCallback(() => {
|
||||||
|
if (disabled || confirming) return;
|
||||||
|
fill.setValue(0);
|
||||||
|
const anim = Animated.parallel([
|
||||||
|
Animated.timing(fill, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: triggerHoldMs,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: 0.98,
|
||||||
|
duration: triggerHoldMs,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
animRef.current = anim;
|
||||||
|
anim.start(({ finished }) => {
|
||||||
|
animRef.current = null;
|
||||||
|
if (!finished) return;
|
||||||
|
impactHeavy();
|
||||||
|
setConfirming(true);
|
||||||
|
fill.setValue(0);
|
||||||
|
Animated.timing(scale, { toValue: 1, duration: 140, useNativeDriver: true }).start();
|
||||||
|
});
|
||||||
|
}, [disabled, confirming, fill, scale]);
|
||||||
|
|
||||||
|
// The sweep tracks the pad's actual width instead of a magic constant.
|
||||||
|
const translateX = fill.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-Math.max(width, 1), 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width || 0);
|
||||||
|
|
||||||
|
const triggerDisabled = disabled || ringing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap}>
|
||||||
|
<Animated.View style={{ transform: [{ scale }] }}>
|
||||||
|
<Pressable
|
||||||
|
onLayout={onLayout}
|
||||||
|
onPressIn={startHold}
|
||||||
|
onPressOut={cancelHold}
|
||||||
|
disabled={triggerDisabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Hold to trigger the alarm. Press and hold for 1.2 seconds."
|
||||||
|
accessibilityHint="Releasing early cancels. You will be asked to confirm."
|
||||||
|
accessibilityState={{ disabled: triggerDisabled }}
|
||||||
|
style={[styles.pad, triggerDisabled ? styles.disabled : null]}
|
||||||
|
>
|
||||||
|
{/* Sweep: the fill is clipped by the pad's rounded corners. */}
|
||||||
|
<Animated.View style={[styles.sweep, { transform: [{ translateX }] }]} />
|
||||||
|
<View style={styles.padContent}>
|
||||||
|
<Zap size={24} color="#FFFFFF" />
|
||||||
|
<AppText style={styles.padLabel}>Hold to trigger</AppText>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<AppText style={styles.hint}>
|
||||||
|
{ringing ? 'Alarm is ringing — disarm to stop it' : TRIGGER_HOLD_LABEL}
|
||||||
|
</AppText>
|
||||||
|
|
||||||
|
{triggerDisabled && (disabledReason || ringing) ? (
|
||||||
|
<AppText style={styles.reason}>{disabledReason ?? 'Already ringing on every device.'}</AppText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label="Disarm"
|
||||||
|
variant="secondary"
|
||||||
|
height={56}
|
||||||
|
onPress={onDisarm}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={OctagonX}
|
||||||
|
accessibilityLabel="Disarm the alarm on every device"
|
||||||
|
/>
|
||||||
|
<AppText style={styles.hint}>Disarm stops the alarm on every device. One tap, no confirm.</AppText>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={confirming}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setConfirming(false)}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View style={styles.scrim}>
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<AppText style={styles.sheetTitle}>
|
||||||
|
{confirmTitle ??
|
||||||
|
`Trigger the alarm on ${deviceCount} device${deviceCount === 1 ? '' : 's'}?`}
|
||||||
|
</AppText>
|
||||||
|
<AppText style={styles.sheetBody}>
|
||||||
|
Every subscribed device rings at maximum volume. Subscribers only hear it — they
|
||||||
|
cannot stop it.
|
||||||
|
</AppText>
|
||||||
|
<Button
|
||||||
|
label="Trigger now"
|
||||||
|
variant="alarm"
|
||||||
|
height={56}
|
||||||
|
onPress={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onTrigger();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
variant="secondary"
|
||||||
|
height={48}
|
||||||
|
onPress={() => setConfirming(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { gap: space6 },
|
||||||
|
pad: {
|
||||||
|
height: 64,
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
// The alarm ramp, NOT `dangerDeep`: this control makes other people's phones
|
||||||
|
// scream, it does not delete anything (D-2).
|
||||||
|
backgroundColor: stateAlarmDeep,
|
||||||
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: minTapTarget,
|
||||||
|
},
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
sweep: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: stateAlarm,
|
||||||
|
},
|
||||||
|
padContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space2,
|
||||||
|
},
|
||||||
|
padLabel: { ...typeBodyStrong, color: '#FFFFFF' },
|
||||||
|
hint: { ...typeCaption, color: textSecondary, textAlign: 'center' },
|
||||||
|
reason: { ...typeCaption, color: stateAlarmInk, textAlign: 'center', marginTop: -space4 },
|
||||||
|
scrim: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(11,13,16,0.72)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderTopLeftRadius: radiusXl,
|
||||||
|
borderTopRightRadius: radiusXl,
|
||||||
|
padding: space6,
|
||||||
|
gap: space4,
|
||||||
|
},
|
||||||
|
sheetTitle: { ...typeTitle, color: text },
|
||||||
|
sheetBody: { ...typeBody, color: textSecondary },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TRIGGER_HOLD_LABEL = `Hold for 1.2s — releases cancel`;
|
||||||
|
export const TRIGGER_DISABLED_HINT = 'Only the alert owner can trigger it.';
|
||||||
|
export const ALARM_INK = stateAlarmInk;
|
||||||
|
export const ALARM_STATE_COLOR = stateAlarm;
|
||||||
|
export const MONO_HINT = typeMicro;
|
||||||
|
export const PAD_LABEL_HINT = typeLabel;
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type TextInputProps,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Text } from 'heroui-native/text';
|
||||||
|
import { Spinner } from 'heroui-native/spinner';
|
||||||
|
import { Surface } from 'heroui-native/surface';
|
||||||
|
import {
|
||||||
|
BellOff,
|
||||||
|
CircleAlert,
|
||||||
|
CircleDashed,
|
||||||
|
ShieldCheck,
|
||||||
|
Siren,
|
||||||
|
TriangleAlert,
|
||||||
|
WifiOff,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import {
|
||||||
|
border,
|
||||||
|
danger,
|
||||||
|
dangerDeep,
|
||||||
|
focusRing,
|
||||||
|
minTapTarget,
|
||||||
|
ok,
|
||||||
|
radiusFull,
|
||||||
|
radiusLg,
|
||||||
|
space1,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
stateAlarmDeep,
|
||||||
|
surface,
|
||||||
|
surfaceRaised,
|
||||||
|
text,
|
||||||
|
textFaint,
|
||||||
|
textMuted,
|
||||||
|
textSecondary,
|
||||||
|
typeBody,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeCode,
|
||||||
|
typeHeading,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
typeTitle,
|
||||||
|
warn,
|
||||||
|
STATE_META,
|
||||||
|
type AlarmState,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
// ── Primitives ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppText({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
numberOfLines,
|
||||||
|
accessibilityLabel,
|
||||||
|
accessibilityLiveRegion,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: object;
|
||||||
|
className?: string;
|
||||||
|
numberOfLines?: number;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
numberOfLines={numberOfLines}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
accessibilityLiveRegion={accessibilityLiveRegion}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Screen({
|
||||||
|
children,
|
||||||
|
scroll = true,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
scroll?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!scroll) {
|
||||||
|
return <View style={styles.flex}>{children}</View>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.flex}
|
||||||
|
contentContainerStyle={styles.screenContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
accessibilityLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<AppText style={{ ...typeLabel, color: textMuted }}>{label}</AppText>
|
||||||
|
<View accessibilityLabel={accessibilityLabel}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInputBox(props: TextInputProps) {
|
||||||
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
const { style, onFocus, onBlur, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
placeholderTextColor={textFaint}
|
||||||
|
onFocus={e => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={e => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
|
// The border token is decorative; a control boundary uses focusRing
|
||||||
|
// (DESIGN.md §2.1).
|
||||||
|
style={[styles.input, { borderColor: focused ? focusRing : border }, style]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
style,
|
||||||
|
accessibilityLabel,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
style?: object;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}) {
|
||||||
|
const inner = (
|
||||||
|
<Surface style={[styles.card, style]}>
|
||||||
|
<View accessibilityLabel={accessibilityLabel}>{children}</View>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonVariant =
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'ghost'
|
||||||
|
| 'alarm'
|
||||||
|
| 'danger'
|
||||||
|
| 'warn'
|
||||||
|
| 'outlineWhite'
|
||||||
|
| 'white';
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
label: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
height?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
style?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_VARIANTS: Record<
|
||||||
|
ButtonVariant,
|
||||||
|
{ fill: string; fg: string; border: string; borderWidth: number }
|
||||||
|
> = {
|
||||||
|
primary: { fill: focusRing, fg: '#06121F', border: 'transparent', borderWidth: 0 },
|
||||||
|
secondary: { fill: surfaceRaised, fg: text, border: border, borderWidth: 1 },
|
||||||
|
ghost: { fill: 'transparent', fg: text, border: 'transparent', borderWidth: 0 },
|
||||||
|
alarm: { fill: stateAlarmDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
|
||||||
|
danger: { fill: dangerDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
|
||||||
|
warn: { fill: warn, fg: '#2A1A02', border: 'transparent', borderWidth: 0 },
|
||||||
|
outlineWhite: { fill: 'transparent', fg: '#FFFFFF', border: '#FFFFFF', borderWidth: 2 },
|
||||||
|
white: { fill: '#FFFFFF', fg: '#0B0D10', border: 'transparent', borderWidth: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
height = 56,
|
||||||
|
disabled,
|
||||||
|
icon: Icon,
|
||||||
|
accessibilityLabel,
|
||||||
|
style,
|
||||||
|
}: ButtonProps) {
|
||||||
|
const palette = BUTTON_VARIANTS[variant];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={disabled ? undefined : onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel ?? label}
|
||||||
|
accessibilityState={{ disabled: !!disabled }}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
height,
|
||||||
|
backgroundColor: palette.fill,
|
||||||
|
borderColor: palette.border,
|
||||||
|
borderWidth: palette.borderWidth,
|
||||||
|
},
|
||||||
|
// Disabled is opacity .4 — never a colour-only change (DESIGN.md).
|
||||||
|
disabled ? styles.disabled : null,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size={20} color={palette.fg} /> : null}
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: palette.fg }}>{label}</AppText>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
{Icon ? <Icon size={36} color={textFaint} /> : null}
|
||||||
|
<AppText style={{ ...typeHeading, color: text, marginTop: space4 }}>{title}</AppText>
|
||||||
|
{subtitle ? (
|
||||||
|
<AppText
|
||||||
|
style={{ ...typeCaption, color: textSecondary, marginTop: space2, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
{action ? <View style={styles.emptyAction}>{action}</View> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<Spinner />
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space3 }}>{label}</AppText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorBanner} accessibilityLiveRegion="polite">
|
||||||
|
<View style={styles.errorRow}>
|
||||||
|
<CircleAlert size={18} color={danger} />
|
||||||
|
<AppText style={{ ...typeLabel, color: danger, flex: 1 }}>{message}</AppText>
|
||||||
|
</View>
|
||||||
|
{onRetry ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onRetry}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Try again"
|
||||||
|
style={styles.retry}
|
||||||
|
>
|
||||||
|
<AppText style={{ ...typeLabel, color: danger, textDecorationLine: 'underline' }}>
|
||||||
|
Try again
|
||||||
|
</AppText>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent offline strip (Cross-screen rules) — never an ErrorBanner. */
|
||||||
|
export function OfflineStrip() {
|
||||||
|
return (
|
||||||
|
<View style={styles.offlineStrip}>
|
||||||
|
<WifiOff size={16} color={warn} />
|
||||||
|
<AppText style={{ ...typeCaption, color: warn, flex: 1 }}>
|
||||||
|
Offline — actions will be sent when you reconnect
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onBack,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{onBack ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Back"
|
||||||
|
style={styles.headerBack}
|
||||||
|
>
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: textMuted }}>‹</AppText>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.flex}>
|
||||||
|
<AppText style={{ ...typeTitle, color: text }} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</AppText>
|
||||||
|
{subtitle ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{right}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATE_ICONS: Record<AlarmState, LucideIcon> = {
|
||||||
|
idle: CircleDashed,
|
||||||
|
armed: ShieldCheck,
|
||||||
|
triggered: Siren,
|
||||||
|
disarmed: BellOff,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stateIcon(state: AlarmState): LucideIcon {
|
||||||
|
return STATE_ICONS[state];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatePill({ state, style }: { state: AlarmState; style?: object }) {
|
||||||
|
const meta = STATE_META[state];
|
||||||
|
const Icon = STATE_ICONS[state];
|
||||||
|
const filled = meta.chipFill !== null;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.pill,
|
||||||
|
{
|
||||||
|
backgroundColor: meta.chipFill ?? 'transparent',
|
||||||
|
borderColor: meta.color,
|
||||||
|
borderWidth: filled ? 0 : 1,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
accessibilityLabel={meta.label}
|
||||||
|
>
|
||||||
|
<Icon size={14} color={filled ? meta.chipText : meta.color} />
|
||||||
|
<AppText style={{ ...typeMicro, color: filled ? meta.chipText : meta.color }}>
|
||||||
|
{meta.label}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breathe (armed, 1800ms) / pulse (triggered, 700ms) / static otherwise. */
|
||||||
|
export function useStateMotion(state: AlarmState, reducedMotion = false) {
|
||||||
|
const anim = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion || (state !== 'armed' && state !== 'triggered')) {
|
||||||
|
anim.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = state === 'armed' ? 1800 : 700;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: state === 'armed' ? 1.04 : 1.06,
|
||||||
|
duration: duration / 2,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: duration / 2,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [state, reducedMotion, anim]);
|
||||||
|
return anim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state ring: width from the state meta, dashed for idle, plus corner ticks
|
||||||
|
* for triggered. Shape + icon + label carry the state, so colour is never the
|
||||||
|
* only signal.
|
||||||
|
*/
|
||||||
|
export function StateRing({
|
||||||
|
state,
|
||||||
|
size = 160,
|
||||||
|
reducedMotion = false,
|
||||||
|
}: {
|
||||||
|
state: AlarmState;
|
||||||
|
size?: number;
|
||||||
|
reducedMotion?: boolean;
|
||||||
|
}) {
|
||||||
|
const meta = STATE_META[state];
|
||||||
|
const Icon = STATE_ICONS[state];
|
||||||
|
const scale = useStateMotion(state, reducedMotion);
|
||||||
|
return (
|
||||||
|
<Animated.View style={{ transform: [{ scale }], width: size, height: size }}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.ring,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderColor: meta.color,
|
||||||
|
borderWidth: meta.ringWidth,
|
||||||
|
borderStyle: meta.dashed ? 'dashed' : 'solid',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Icon size={48} color={meta.color} />
|
||||||
|
</View>
|
||||||
|
{state === 'triggered' ? (
|
||||||
|
<>
|
||||||
|
<View style={[styles.tickTopLeft, { borderTopColor: meta.color }]} />
|
||||||
|
<View style={[styles.tickTopRight, { borderTopColor: meta.color }]} />
|
||||||
|
<View style={[styles.tickBottomLeft, { borderBottomColor: meta.color }]} />
|
||||||
|
<View style={[styles.tickBottomRight, { borderBottomColor: meta.color }]} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLine({
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
color: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.iconLine}>
|
||||||
|
<Icon size={16} color={color} />
|
||||||
|
<AppText style={{ ...typeCaption, color, flex: 1 }}>{children}</AppText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trailing readiness icon: circle-check (ok) / triangle-alert / circle-alert. */
|
||||||
|
export function HealthIcon({ status }: { status: 'ok' | 'warn' | 'danger' }) {
|
||||||
|
if (status === 'ok') {
|
||||||
|
return (
|
||||||
|
<View style={styles.checkCircle}>
|
||||||
|
<AppText style={{ ...typeMicro, color: '#06240F' }}>✓</AppText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'warn') return <TriangleAlert size={20} color={warn} />;
|
||||||
|
return <CircleAlert size={20} color={danger} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Divider() {
|
||||||
|
return <View style={styles.divider} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const styles = StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
screenContent: { padding: space4, paddingBottom: 40 },
|
||||||
|
field: { gap: space2 },
|
||||||
|
input: {
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
borderWidth: 1,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingVertical: space3,
|
||||||
|
color: text,
|
||||||
|
...typeBody,
|
||||||
|
},
|
||||||
|
card: { backgroundColor: surface, borderRadius: radiusLg, padding: space4, elevation: 4 },
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space2,
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
minHeight: minTapTarget,
|
||||||
|
},
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
empty: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: space6,
|
||||||
|
paddingVertical: 48,
|
||||||
|
},
|
||||||
|
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
|
||||||
|
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 },
|
||||||
|
errorBanner: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(248,113,113,0.3)',
|
||||||
|
backgroundColor: 'rgba(248,113,113,0.1)',
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
padding: space4,
|
||||||
|
marginBottom: space4,
|
||||||
|
},
|
||||||
|
errorRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
||||||
|
retry: { marginTop: space2, minHeight: 32, justifyContent: 'center' },
|
||||||
|
offlineStrip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space2,
|
||||||
|
backgroundColor: 'rgba(251,191,36,0.12)',
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingVertical: space2,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space2,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingVertical: space3,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: border,
|
||||||
|
},
|
||||||
|
headerBack: {
|
||||||
|
width: minTapTarget,
|
||||||
|
height: minTapTarget,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space1,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
paddingHorizontal: space3,
|
||||||
|
paddingVertical: space1,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
ring: { alignItems: 'center', justifyContent: 'center', borderRadius: radiusFull },
|
||||||
|
tickTopLeft: { position: 'absolute', top: 0, left: 0, width: 18, height: 18, borderTopWidth: 4 },
|
||||||
|
tickTopRight: { position: 'absolute', top: 0, right: 0, width: 18, height: 18, borderTopWidth: 4 },
|
||||||
|
tickBottomLeft: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderBottomWidth: 4,
|
||||||
|
},
|
||||||
|
tickBottomRight: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderBottomWidth: 4,
|
||||||
|
},
|
||||||
|
iconLine: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
||||||
|
checkCircle: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
backgroundColor: ok,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
divider: { height: 1, backgroundColor: border, marginVertical: space3 },
|
||||||
|
mono: { ...typeCode, color: text },
|
||||||
|
body: { ...typeBody, color: text },
|
||||||
|
caption: { ...typeCaption, color: textSecondary },
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* QR payload contract for the Alarm client.
|
||||||
|
*
|
||||||
|
* Canonical payload (frozen contract):
|
||||||
|
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||||
|
*
|
||||||
|
* Also accepted:
|
||||||
|
* - the same URL with a trailing slash / query noise,
|
||||||
|
* - a bare custom scheme: `dcntrlized-alarm://a/{alertId}#{token}`,
|
||||||
|
* - a relative `a/{alertId}#{token}`,
|
||||||
|
* - raw `{alertId}#{token}` (what the owner reads off the QR panel),
|
||||||
|
* - a pasted token alone when the caller already knows the alertId
|
||||||
|
* (manual fallback) — see `parseManualToken`.
|
||||||
|
*
|
||||||
|
* This module is intentionally pure (no React, no RN imports) so it is testable
|
||||||
|
* under jest without a native environment, and so the same parser is used by
|
||||||
|
* the scanner, the deep-link handler and the manual paste field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const QR_HOST = 'alarm.dcntrlized.org';
|
||||||
|
export const QR_SCHEME = 'dcntrlized-alarm';
|
||||||
|
export const QR_PATH_PREFIX = '/a/';
|
||||||
|
|
||||||
|
export interface QrPayload {
|
||||||
|
alertId: string;
|
||||||
|
subscribeToken: string;
|
||||||
|
/** Where the payload came from — drives copy in the scanner error states. */
|
||||||
|
source: 'url' | 'scheme' | 'relative' | 'raw';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QrParseError =
|
||||||
|
| 'empty'
|
||||||
|
| 'not-an-alarm-code'
|
||||||
|
| 'missing-alert-id'
|
||||||
|
| 'missing-token';
|
||||||
|
|
||||||
|
export type QrParseResult =
|
||||||
|
| { ok: true; payload: QrPayload }
|
||||||
|
| { ok: false; error: QrParseError };
|
||||||
|
|
||||||
|
const OK = (payload: QrPayload): QrParseResult => ({ ok: true, payload });
|
||||||
|
const FAIL = (error: QrParseError): QrParseResult => ({ ok: false, error });
|
||||||
|
|
||||||
|
/** Canonical payload an owner renders into a QR code. */
|
||||||
|
export function buildQrPayload(alertId: string, subscribeToken: string): string {
|
||||||
|
return `https://${QR_HOST}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom-scheme form, used by deep links and by the "share" affordance. */
|
||||||
|
export function buildQrSchemePayload(alertId: string, subscribeToken: string): string {
|
||||||
|
return `${QR_SCHEME}://a/${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFragment(value: string): [string, string] {
|
||||||
|
const i = value.indexOf('#');
|
||||||
|
return i === -1 ? [value, ''] : [value.slice(0, i), value.slice(i + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuery(value: string): string {
|
||||||
|
const i = value.indexOf('?');
|
||||||
|
return i === -1 ? value : value.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the alert id from a path. `/a/{id}` is the contract path; the id is
|
||||||
|
* whatever follows the `a/` prefix, so a trailing slash yields "" (missing)
|
||||||
|
* rather than the literal "a".
|
||||||
|
*/
|
||||||
|
function alertIdFromPath(path: string): string {
|
||||||
|
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const segments = trimmed.split('/').filter(Boolean);
|
||||||
|
if (segments[0] === 'a') {
|
||||||
|
return segments.length > 1 ? segments[1] : '';
|
||||||
|
}
|
||||||
|
return segments[segments.length - 1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a scanned / pasted QR payload. Never throws.
|
||||||
|
*/
|
||||||
|
export function parseQrPayload(raw: string): QrParseResult {
|
||||||
|
const trimmed = (raw ?? '').trim();
|
||||||
|
if (!trimmed) return FAIL('empty');
|
||||||
|
|
||||||
|
const [head, fragment] = splitFragment(trimmed);
|
||||||
|
const withoutQuery = stripQuery(head);
|
||||||
|
const token = decodeURIComponent(fragment.trim());
|
||||||
|
|
||||||
|
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(withoutQuery);
|
||||||
|
if (schemeMatch) {
|
||||||
|
const scheme = schemeMatch[1].toLowerCase();
|
||||||
|
if (scheme !== QR_SCHEME && scheme !== 'http' && scheme !== 'https') {
|
||||||
|
return FAIL('not-an-alarm-code');
|
||||||
|
}
|
||||||
|
const rest = withoutQuery.slice(schemeMatch[0].length);
|
||||||
|
// For http(s) the first segment is the authority (`host[:port]`); for the
|
||||||
|
// custom scheme `rest` is already a path.
|
||||||
|
let path: string;
|
||||||
|
if (scheme === 'http' || scheme === 'https') {
|
||||||
|
const slash = rest.indexOf('/');
|
||||||
|
const authority = (slash === -1 ? rest : rest.slice(0, slash)).split(':')[0].toLowerCase();
|
||||||
|
const hostOk =
|
||||||
|
authority === QR_HOST ||
|
||||||
|
authority.endsWith(`.${QR_HOST}`) ||
|
||||||
|
authority === 'localhost' ||
|
||||||
|
authority === '127.0.0.1' ||
|
||||||
|
/^\d{1,3}(\.\d{1,3}){3}$/.test(authority);
|
||||||
|
if (!hostOk) return FAIL('not-an-alarm-code');
|
||||||
|
path = slash === -1 ? '' : rest.slice(slash);
|
||||||
|
} else {
|
||||||
|
path = rest.startsWith('/') ? rest : `/${rest}`;
|
||||||
|
}
|
||||||
|
const seg = alertIdFromPath(path);
|
||||||
|
if (!seg) return FAIL('missing-alert-id');
|
||||||
|
if (!token) return FAIL('missing-token');
|
||||||
|
return OK({
|
||||||
|
alertId: decodeURIComponent(seg),
|
||||||
|
subscribeToken: token,
|
||||||
|
source: scheme === QR_SCHEME ? 'scheme' : 'url',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No scheme: accept `a/{id}#{token}` or `{id}#{token}`.
|
||||||
|
const cleaned = withoutQuery.replace(/^\/+/, '');
|
||||||
|
const seg = alertIdFromPath(cleaned);
|
||||||
|
if (!seg) return FAIL('missing-alert-id');
|
||||||
|
if (!token) return FAIL('missing-token');
|
||||||
|
const looksRelative = cleaned.startsWith('a/') || cleaned.startsWith(`${QR_SCHEME}/`);
|
||||||
|
return OK({
|
||||||
|
alertId: decodeURIComponent(seg),
|
||||||
|
subscribeToken: token,
|
||||||
|
source: looksRelative ? 'relative' : 'raw',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual fallback: the user pastes either a full payload or just the token
|
||||||
|
* fragment, while the alertId is already known (they are on the alert's join
|
||||||
|
* screen or typed the id in).
|
||||||
|
*/
|
||||||
|
export function parseManualToken(input: string, alertId: string): QrParseResult {
|
||||||
|
const trimmed = (input ?? '').trim();
|
||||||
|
if (!trimmed) return FAIL('empty');
|
||||||
|
const withId = parseQrPayload(trimmed);
|
||||||
|
if (withId.ok) {
|
||||||
|
return withId;
|
||||||
|
}
|
||||||
|
if (!alertId.trim()) {
|
||||||
|
return withId.error === 'missing-token' ? FAIL('missing-alert-id') : withId;
|
||||||
|
}
|
||||||
|
// A bare token: no scheme, no '#', no '/'.
|
||||||
|
if (/^[A-Za-z0-9._~-]+$/.test(trimmed)) {
|
||||||
|
return OK({ alertId: alertId.trim(), subscribeToken: trimmed, source: 'raw' });
|
||||||
|
}
|
||||||
|
return withId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human copy for a parse failure (scanner error states, DESIGN.md §7.5). */
|
||||||
|
export function qrErrorMessage(error: QrParseError): string {
|
||||||
|
switch (error) {
|
||||||
|
case 'empty':
|
||||||
|
return 'Nothing was scanned';
|
||||||
|
case 'not-an-alarm-code':
|
||||||
|
return 'Not a Dcntrlized alert code';
|
||||||
|
case 'missing-alert-id':
|
||||||
|
return 'This code has no alert id';
|
||||||
|
case 'missing-token':
|
||||||
|
return 'This code has no subscribe token';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tokens render truncated to 12 chars + ellipsis (DESIGN.md §7.4). */
|
||||||
|
export function truncateToken(token: string, max = 12): string {
|
||||||
|
if (token.length <= max) return token;
|
||||||
|
return `${token.slice(0, max)}…`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Human time helpers for the alarm surfaces.
|
||||||
|
*
|
||||||
|
* The takeover shows a count-up ("2s ago") rather than a server timestamp: on a
|
||||||
|
* lock screen at arm's length an RFC3339 string is unreadable, and the number
|
||||||
|
* that matters is how long the phone has been screaming. The absolute time is
|
||||||
|
* still available as the accessibility label.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parse a server RFC3339 string. Returns null when absent or unparseable. */
|
||||||
|
export function parseServerTime(value: string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const ms = Date.parse(value);
|
||||||
|
return Number.isNaN(ms) ? null : new Date(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECOND = 1000;
|
||||||
|
const MINUTE = 60 * SECOND;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact elapsed time. Deliberately coarse above a minute: the takeover updates
|
||||||
|
* every second only while the alarm is fresh, and "13m ago" is more useful than
|
||||||
|
* "13m 42s ago" on a wall of red.
|
||||||
|
*/
|
||||||
|
export function relativeTime(from: Date, now: number): string {
|
||||||
|
const elapsed = now - from.getTime();
|
||||||
|
// A clock skew between phone and server must not render "in 3 seconds".
|
||||||
|
if (elapsed < SECOND) return 'just now';
|
||||||
|
if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`;
|
||||||
|
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
|
||||||
|
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
|
||||||
|
return `${Math.floor(elapsed / DAY)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: relative time for a server string, or null when unparseable. */
|
||||||
|
export function relativeSince(value: string | null | undefined, now: number): string | null {
|
||||||
|
const date = parseServerTime(value);
|
||||||
|
return date ? relativeTime(date, now) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock time for the accessibility label of a relative stamp. */
|
||||||
|
export function absoluteLocal(date: Date): string {
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type RootStackParamList = {
|
||||||
|
Login: undefined;
|
||||||
|
Home: undefined;
|
||||||
|
/** Admin control surface (viewer holds the admin token). */
|
||||||
|
AlertDetail: { alertId: string; role?: 'admin' | 'subscriber' };
|
||||||
|
QrDisplay: { alertId: string };
|
||||||
|
Scanner: { alertId?: string } | undefined;
|
||||||
|
Settings: undefined;
|
||||||
|
/** Live alarm takeover — presented as a modal outside the tab shell. */
|
||||||
|
LiveAlarm: { alertId: string; eventId?: string; message?: string; serverTime?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HomeTab = 'admin' | 'subscribed';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createNavigationContainerRef } from '@react-navigation/native';
|
||||||
|
import type { RootStackParamList } from './navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A navigation ref so non-React code (the alarm socket callbacks, the native
|
||||||
|
* foreground service's intent handling) can raise the takeover without being
|
||||||
|
* inside a screen. `Root` in App.tsx keeps the auth-driven resets; this ref is
|
||||||
|
* only used for the alarm takeover, which must work from any screen.
|
||||||
|
*/
|
||||||
|
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||||
|
|
||||||
|
export function openLiveAlarm(params: RootStackParamList['LiveAlarm']): void {
|
||||||
|
if (!navigationRef.isReady()) return;
|
||||||
|
const current = navigationRef.getCurrentRoute()?.name;
|
||||||
|
if (current === 'LiveAlarm') {
|
||||||
|
// Re-fire on an already-open takeover: replace so the newest event/message
|
||||||
|
// is what is rendered.
|
||||||
|
navigationRef.dispatch(state => ({
|
||||||
|
...state,
|
||||||
|
routes: state.routes.map(route =>
|
||||||
|
route.name === 'LiveAlarm' ? { ...route, params } : route,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigationRef.navigate('LiveAlarm', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeLiveAlarm(): void {
|
||||||
|
if (!navigationRef.isReady()) return;
|
||||||
|
if (navigationRef.getCurrentRoute()?.name !== 'LiveAlarm') return;
|
||||||
|
if (navigationRef.canGoBack()) {
|
||||||
|
navigationRef.goBack();
|
||||||
|
} else {
|
||||||
|
navigationRef.navigate('Home');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BellOff,
|
||||||
|
Copy,
|
||||||
|
QrCode,
|
||||||
|
RefreshCw,
|
||||||
|
ScanLine,
|
||||||
|
ShieldCheck,
|
||||||
|
Smartphone,
|
||||||
|
Trash2,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import Clipboard from '@react-native-clipboard/clipboard';
|
||||||
|
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
|
||||||
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
|
import * as api from '../services/alarmApi';
|
||||||
|
import { formatAutoStop } from '../services/alarmApi';
|
||||||
|
import { relativeSince } from '../lib/time';
|
||||||
|
import { truncateToken } from '../lib/qrPayload';
|
||||||
|
import { TriggerPad } from '../components/TriggerPad';
|
||||||
|
import {
|
||||||
|
AppText,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
EmptyState,
|
||||||
|
ErrorBanner,
|
||||||
|
Field,
|
||||||
|
Header,
|
||||||
|
IconLine,
|
||||||
|
LoadingView,
|
||||||
|
OfflineStrip,
|
||||||
|
StatePill,
|
||||||
|
StateRing,
|
||||||
|
TextInputBox,
|
||||||
|
} from '../components/ui';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import {
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
danger,
|
||||||
|
focusRing,
|
||||||
|
minTapTarget,
|
||||||
|
radiusLg,
|
||||||
|
radiusMd,
|
||||||
|
space1,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
stateArmed,
|
||||||
|
stateDisarmed,
|
||||||
|
surfaceRaised,
|
||||||
|
text,
|
||||||
|
textFaint,
|
||||||
|
textMuted,
|
||||||
|
textSecondary,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeCode,
|
||||||
|
typeHeading,
|
||||||
|
typeMicro,
|
||||||
|
warn,
|
||||||
|
type AlarmState,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
|
||||||
|
|
||||||
|
const NEW = 'new';
|
||||||
|
|
||||||
|
export default function AlertDetailScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { params } = useRoute<Rt>();
|
||||||
|
const alertId = params?.alertId ?? NEW;
|
||||||
|
|
||||||
|
if (alertId === NEW) return <CreateAlertView onCreated={id => navigation.replace('AlertDetail', { alertId: id, role: 'admin' })} />;
|
||||||
|
|
||||||
|
return <AlertDetailView alertId={alertId} role={params?.role} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }) {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { createAlert } = useAlarm();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [autoStop, setAutoStop] = useState('60');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const seconds = Number.parseInt(autoStop, 10);
|
||||||
|
const created = await createAlert({
|
||||||
|
name: name.trim(),
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
|
||||||
|
});
|
||||||
|
onCreated(created.alertId);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not create the alert.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Header title="New alert" subtitle="Create the alert, then show its code" onBack={() => navigation.goBack()} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>What is this alert for?</AppText>
|
||||||
|
<Field label="Alert name">
|
||||||
|
<TextInputBox
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Front door"
|
||||||
|
accessibilityLabel="Alert name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Message (optional)">
|
||||||
|
<TextInputBox
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
placeholder="Come to the front door"
|
||||||
|
accessibilityLabel="Message"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Auto-stop (seconds)">
|
||||||
|
<TextInputBox
|
||||||
|
value={autoStop}
|
||||||
|
onChangeText={setAutoStop}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="60"
|
||||||
|
accessibilityLabel="Auto-stop in seconds"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
|
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
|
||||||
|
after this, on every device.
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <ErrorBanner message={error} onRetry={submit} /> : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={busy ? 'Creating…' : 'Create alert'}
|
||||||
|
icon={ShieldCheck}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
/>
|
||||||
|
<AppText style={styles.note}>
|
||||||
|
The owner token is shown once, right after creation. Store it before you leave the next
|
||||||
|
screen — it cannot be recovered.
|
||||||
|
</AppText>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { online } = useConnection();
|
||||||
|
const {
|
||||||
|
adminAlerts,
|
||||||
|
getAlert,
|
||||||
|
adminTokenFor,
|
||||||
|
updateAlertLocal,
|
||||||
|
forgetAdminAlert,
|
||||||
|
leaveAlert,
|
||||||
|
liveEvent,
|
||||||
|
refresh,
|
||||||
|
setActiveAlertId,
|
||||||
|
delivery,
|
||||||
|
socketState,
|
||||||
|
} = useAlarm();
|
||||||
|
|
||||||
|
const alert = getAlert(alertId);
|
||||||
|
const isAdmin = role === 'admin' || !!adminAlerts.find(a => a.alertId === alertId);
|
||||||
|
const adminToken = adminTokenFor(alertId);
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
|
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
|
||||||
|
|
||||||
|
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||||
|
isAdmin ? alertId : null,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read the authoritative state on mount; the local copy is only a cache.
|
||||||
|
const loadServerAlert = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await api.getAlert(alertId);
|
||||||
|
setServerAlert(fresh);
|
||||||
|
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
||||||
|
} catch {
|
||||||
|
// Offline is fine — the cached copy renders.
|
||||||
|
}
|
||||||
|
}, [alertId, updateAlertLocal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveAlertId(isAdmin ? null : alertId);
|
||||||
|
loadServerAlert();
|
||||||
|
}, [alertId, isAdmin, loadServerAlert, setActiveAlertId]);
|
||||||
|
|
||||||
|
// `AlertStatus` IS the four-state ramp — no mapping needed.
|
||||||
|
const state: AlarmState = serverAlert?.status ?? alert?.status ?? 'idle';
|
||||||
|
// `armed`/`disarmed` are ringing-state history, not an enablement gate: there
|
||||||
|
// is no arm control and no `armAlert` action, and the service accepts a
|
||||||
|
// trigger from any state. So the copy labels the last event instead of
|
||||||
|
// promising a re-arm that does not exist.
|
||||||
|
const lastEventAt = serverAlert?.lastEvent?.serverTime ?? serverAlert?.triggeredAt ?? serverAlert?.disarmedAt;
|
||||||
|
const lastEventAgo = relativeSince(lastEventAt, Date.now());
|
||||||
|
const stateSentence =
|
||||||
|
state === 'triggered'
|
||||||
|
? 'Ringing now on every subscribed device.'
|
||||||
|
: state === 'disarmed'
|
||||||
|
? lastEventAgo
|
||||||
|
? `Disarmed ${lastEventAgo} — it will ring again the moment the owner triggers it.`
|
||||||
|
: 'Disarmed — it will ring again the moment the owner triggers it.'
|
||||||
|
: state === 'armed'
|
||||||
|
? lastEventAgo
|
||||||
|
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
|
||||||
|
: 'Armed — triggering rings every subscribed device.'
|
||||||
|
: 'Idle — triggering rings every subscribed device.';
|
||||||
|
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
|
||||||
|
const effectiveState = live ? 'triggered' : state;
|
||||||
|
const deviceCount = subs.length;
|
||||||
|
|
||||||
|
const doTrigger = async () => {
|
||||||
|
if (!adminToken) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.triggerAlert({ alertId, adminToken });
|
||||||
|
updateAlertLocal(alertId, { status: 'triggered', lastTriggered: res.serverTime });
|
||||||
|
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
|
||||||
|
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDisarm = async () => {
|
||||||
|
if (!adminToken) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.disarmAlert({ alertId, adminToken });
|
||||||
|
updateAlertLocal(alertId, { status: 'disarmed' });
|
||||||
|
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
|
||||||
|
setNotice('Disarmed on every device.');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!adminToken) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.deleteAlert({ alertId, adminToken });
|
||||||
|
await forgetAdminAlert(alertId);
|
||||||
|
navigation.goBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLeave = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await leaveAlert(alertId);
|
||||||
|
navigation.goBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not leave this alert.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (value: string, label: string) => {
|
||||||
|
try {
|
||||||
|
Clipboard.setString(value);
|
||||||
|
setNotice(`${label} copied.`);
|
||||||
|
} catch {
|
||||||
|
setNotice('Could not copy — long-press the value to select it.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!alert && !serverAlert) {
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Header title="Alert" onBack={() => navigation.goBack()} />
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertTriangle}
|
||||||
|
title="Alert not found on this device"
|
||||||
|
subtitle="This device has no token for that alert. Scan its QR code again to rejoin."
|
||||||
|
action={<Button label="Scan a QR code" icon={ScanLine} onPress={() => navigation.navigate('Scanner')} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
{!online ? <OfflineStrip /> : null}
|
||||||
|
<Header
|
||||||
|
title={alert?.name ?? serverAlert?.name ?? alertId}
|
||||||
|
subtitle={isAdmin ? 'Owner controls' : 'Subscribed device'}
|
||||||
|
onBack={() => navigation.goBack()}
|
||||||
|
right={
|
||||||
|
<Pressable
|
||||||
|
onPress={refresh}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh status"
|
||||||
|
style={styles.iconButton}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} color={textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
{error ? <ErrorBanner message={error} onRetry={loadServerAlert} /> : null}
|
||||||
|
{notice ? (
|
||||||
|
<View style={styles.notice} accessibilityLiveRegion="polite">
|
||||||
|
<AppText style={{ ...typeCaption, color: stateDisarmed }}>{notice}</AppText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.ringWrap}>
|
||||||
|
<StateRing state={effectiveState} />
|
||||||
|
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
|
||||||
|
<AppText style={styles.stateLine}>{stateSentence}</AppText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<AdminControls
|
||||||
|
adminToken={adminToken}
|
||||||
|
revealToken={revealToken}
|
||||||
|
onRevealToken={() => setRevealToken(true)}
|
||||||
|
onCopy={copy}
|
||||||
|
deviceCount={deviceCount}
|
||||||
|
busy={busy}
|
||||||
|
ringing={effectiveState === 'triggered'}
|
||||||
|
onTrigger={doTrigger}
|
||||||
|
onDisarm={doDisarm}
|
||||||
|
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
|
||||||
|
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SubscriberControls
|
||||||
|
alertId={alertId}
|
||||||
|
alert={alert}
|
||||||
|
delivery={delivery}
|
||||||
|
socketState={socketState}
|
||||||
|
busy={busy}
|
||||||
|
onLeave={doLeave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<View style={styles.sectionHead}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Subscribed devices</AppText>
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>{deviceCount}</AppText>
|
||||||
|
</View>
|
||||||
|
{subsError ? <ErrorBanner message={subsError} onRetry={reloadSubs} /> : null}
|
||||||
|
{subsLoading && !subs.length ? (
|
||||||
|
<LoadingView label="Loading devices…" />
|
||||||
|
) : subs.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<IconLine icon={Smartphone} color={textMuted}>
|
||||||
|
No device has scanned this alert yet. Show the QR code to subscribe one.
|
||||||
|
</IconLine>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
subs.map(sub => (
|
||||||
|
<Card key={sub.subscriptionId}>
|
||||||
|
<View style={styles.subRow}>
|
||||||
|
<View style={{ flex: 1, gap: space1 }}>
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: text }}>
|
||||||
|
{sub.deviceLabel || sub.deviceId}
|
||||||
|
</AppText>
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
|
||||||
|
reloadSubs();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
|
||||||
|
style={styles.iconButton}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} color={danger} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Destructive ramp is separate from the alarm ramp (DESIGN.md §2.3). */}
|
||||||
|
{isAdmin ? (
|
||||||
|
<View style={{ gap: space2 }}>
|
||||||
|
<Button
|
||||||
|
label="Delete alert"
|
||||||
|
variant="danger"
|
||||||
|
icon={Trash2}
|
||||||
|
disabled={busy}
|
||||||
|
onPress={doDelete}
|
||||||
|
/>
|
||||||
|
<AppText style={styles.note}>
|
||||||
|
Deleting removes the alert and its tokens. Every subscribed device stops receiving it.
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: space2 }}>
|
||||||
|
<Button label="Leave this alert" variant="danger" icon={BellOff} disabled={busy} onPress={doLeave} />
|
||||||
|
<AppText style={styles.note}>
|
||||||
|
Leaving removes this device from the alert. You can rejoin with the QR code.
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminControls({
|
||||||
|
adminToken,
|
||||||
|
revealToken,
|
||||||
|
onRevealToken,
|
||||||
|
onCopy,
|
||||||
|
deviceCount,
|
||||||
|
busy,
|
||||||
|
ringing,
|
||||||
|
onTrigger,
|
||||||
|
onDisarm,
|
||||||
|
onShowQr,
|
||||||
|
autoStopSeconds,
|
||||||
|
}: {
|
||||||
|
adminToken: string | null;
|
||||||
|
revealToken: boolean;
|
||||||
|
onRevealToken: () => void;
|
||||||
|
onCopy: (value: string, label: string) => void;
|
||||||
|
deviceCount: number;
|
||||||
|
busy: boolean;
|
||||||
|
/** Does the alarm ring on other devices right now (drives the pad's copy). */
|
||||||
|
ringing: boolean;
|
||||||
|
onTrigger: () => void;
|
||||||
|
onDisarm: () => void;
|
||||||
|
onShowQr: () => void;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ gap: space4 }}>
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Owner token</AppText>
|
||||||
|
{adminToken ? (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => (revealToken ? onCopy(adminToken, 'Owner token') : onRevealToken())}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={revealToken ? 'Copy owner token' : 'Reveal owner token'}
|
||||||
|
style={styles.tokenBox}
|
||||||
|
>
|
||||||
|
<AppText style={{ ...typeCode, color: text }}>
|
||||||
|
{revealToken ? truncateToken(adminToken, 24) : truncateToken(adminToken)}
|
||||||
|
</AppText>
|
||||||
|
<Copy size={16} color={textFaint} />
|
||||||
|
</Pressable>
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
{revealToken ? 'Tap to copy' : 'Tap to reveal the first 24 characters'}
|
||||||
|
</AppText>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<IconLine icon={AlertTriangle} color={warn}>
|
||||||
|
No owner token is stored on this device, so this alert cannot be triggered from here.
|
||||||
|
</IconLine>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Control</AppText>
|
||||||
|
<TriggerPad
|
||||||
|
ringing={ringing}
|
||||||
|
onTrigger={onTrigger}
|
||||||
|
onDisarm={onDisarm}
|
||||||
|
disabled={busy || !adminToken}
|
||||||
|
disabledReason={
|
||||||
|
!adminToken
|
||||||
|
? 'This device has no owner token, so it cannot trigger the alarm.'
|
||||||
|
: busy
|
||||||
|
? 'Working…'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
deviceCount={deviceCount}
|
||||||
|
/>
|
||||||
|
{autoStopSeconds ? (
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card onPress={onShowQr} accessibilityLabel="Show the QR code to subscribe a device">
|
||||||
|
<View style={styles.qrRow}>
|
||||||
|
<QrCode size={22} color={focusRing} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: text }}>Show QR code</AppText>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
|
Subscribes a device in one scan
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriberControls({
|
||||||
|
alert,
|
||||||
|
delivery,
|
||||||
|
socketState,
|
||||||
|
busy,
|
||||||
|
onLeave,
|
||||||
|
}: {
|
||||||
|
alertId: string;
|
||||||
|
alert: api.Alert | undefined;
|
||||||
|
delivery: string;
|
||||||
|
socketState: string;
|
||||||
|
busy: boolean;
|
||||||
|
onLeave: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
|
||||||
|
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
|
||||||
|
{delivery === 'socket'
|
||||||
|
? `Connected for live delivery (${socketState}).`
|
||||||
|
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
|
||||||
|
</IconLine>
|
||||||
|
<IconLine icon={ShieldCheck} color={stateArmed}>
|
||||||
|
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
|
||||||
|
</IconLine>
|
||||||
|
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: bg },
|
||||||
|
content: { padding: space4, paddingBottom: 48, gap: space4 },
|
||||||
|
iconButton: {
|
||||||
|
width: minTapTarget,
|
||||||
|
height: minTapTarget,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(95,224,200,0.3)',
|
||||||
|
backgroundColor: 'rgba(95,224,200,0.1)',
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
padding: space3,
|
||||||
|
},
|
||||||
|
ringWrap: { alignItems: 'center', paddingVertical: space6 },
|
||||||
|
stateLine: { ...typeCaption, color: textSecondary, textAlign: 'center', marginTop: space3 },
|
||||||
|
sectionHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
|
||||||
|
subRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||||
|
tokenBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: space3,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
borderRadius: radiusMd,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
minHeight: minTapTarget,
|
||||||
|
},
|
||||||
|
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||||
|
note: { ...typeCaption, color: textSecondary },
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Alert as RNAlert, Pressable, RefreshControl, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import {
|
||||||
|
BellRing,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
QrCode,
|
||||||
|
ScanLine,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
ShieldCheck,
|
||||||
|
Siren,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
|
import { useAuth } from '../state/AuthContext';
|
||||||
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
|
import {
|
||||||
|
AppText,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
ErrorBanner,
|
||||||
|
LoadingView,
|
||||||
|
OfflineStrip,
|
||||||
|
StatePill,
|
||||||
|
} from '../components/ui';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import type { Alert } from '../services/alarmApi';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
focusRing,
|
||||||
|
minTapTarget,
|
||||||
|
radiusFull,
|
||||||
|
radiusLg,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
space10,
|
||||||
|
stateAlarm,
|
||||||
|
surface,
|
||||||
|
surfaceRaised,
|
||||||
|
text,
|
||||||
|
textFaint,
|
||||||
|
textMuted,
|
||||||
|
textSecondary,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
typeTitle,
|
||||||
|
type AlarmState,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type Tab = 'admin' | 'subscribed';
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { username, logout } = useAuth();
|
||||||
|
const { online } = useConnection();
|
||||||
|
const {
|
||||||
|
adminAlerts,
|
||||||
|
subscribedAlerts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
reload,
|
||||||
|
liveEvent,
|
||||||
|
delivery,
|
||||||
|
socketState,
|
||||||
|
} = useAlarm();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('admin');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await reload();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = tab === 'admin' ? adminAlerts : subscribedAlerts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
{!online ? <OfflineStrip /> : null}
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
|
{username ? `Signed in as ${username}` : 'Max-volume alerts for your devices'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate('Settings')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Settings and health"
|
||||||
|
style={styles.iconButton}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={22} color={textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Live banner: only while an alarm is actually up. */}
|
||||||
|
{liveEvent?.action === 'trigger' ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate('LiveAlarm', { alertId: liveEvent.alertId })}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="An alarm is live. Open the takeover."
|
||||||
|
style={styles.liveBanner}
|
||||||
|
>
|
||||||
|
<Siren size={20} color="#FFFFFF" />
|
||||||
|
<AppText style={{ ...typeLabel, color: '#FFFFFF', flex: 1 }}>
|
||||||
|
Alarm live on {liveEvent.alertId}
|
||||||
|
</AppText>
|
||||||
|
<ChevronRight size={18} color="#FFFFFF" />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.tabs}>
|
||||||
|
<TabButton
|
||||||
|
label="My alerts"
|
||||||
|
icon={ShieldCheck}
|
||||||
|
active={tab === 'admin'}
|
||||||
|
count={adminAlerts.length}
|
||||||
|
onPress={() => setTab('admin')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Subscribed"
|
||||||
|
icon={BellRing}
|
||||||
|
active={tab === 'subscribed'}
|
||||||
|
count={subscribedAlerts.length}
|
||||||
|
onPress={() => setTab('subscribed')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
|
>
|
||||||
|
{error ? <ErrorBanner message={error} onRetry={reload} /> : null}
|
||||||
|
|
||||||
|
{loading && !list.length ? (
|
||||||
|
<LoadingView label="Loading your alerts…" />
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
tab === 'admin' ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldCheck}
|
||||||
|
title="No alerts yet"
|
||||||
|
subtitle="Create an alert, show its QR code, and every device that scans it will ring when you trigger it."
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
label="Create an alert"
|
||||||
|
icon={Plus}
|
||||||
|
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={QrCode}
|
||||||
|
title="Not subscribed to anything"
|
||||||
|
subtitle="Scan the QR code the alert owner shows you, or paste the code by hand."
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
label="Scan a QR code"
|
||||||
|
icon={ScanLine}
|
||||||
|
onPress={() => navigation.navigate('Scanner')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
{list.map(alert => (
|
||||||
|
<AlertRow
|
||||||
|
key={alert.alertId}
|
||||||
|
alert={alert}
|
||||||
|
live={liveEvent?.alertId === alert.alertId && liveEvent.action === 'trigger'}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('AlertDetail', { alertId: alert.alertId, role: alert.role })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{tab === 'subscribed' ? (
|
||||||
|
<AppText style={styles.deliveryNote}>
|
||||||
|
{delivery === 'socket'
|
||||||
|
? `Live delivery connected (${socketState}).`
|
||||||
|
: 'Live delivery unavailable in proxy mode — status is polled every 5s.'}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.footerActions}>
|
||||||
|
{tab === 'admin' ? (
|
||||||
|
<Button
|
||||||
|
label="New alert"
|
||||||
|
icon={Plus}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
label="Scan a QR code"
|
||||||
|
icon={ScanLine}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => navigation.navigate('Scanner')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Sign out"
|
||||||
|
variant="ghost"
|
||||||
|
height={44}
|
||||||
|
onPress={() =>
|
||||||
|
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Sign out', style: 'destructive', onPress: logout },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
active,
|
||||||
|
count,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: typeof ShieldCheck;
|
||||||
|
active: boolean;
|
||||||
|
count: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: active }}
|
||||||
|
accessibilityLabel={`${label}, ${count} alert${count === 1 ? '' : 's'}`}
|
||||||
|
style={[styles.tab, active ? styles.tabActive : null]}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={active ? focusRing : textMuted} />
|
||||||
|
<AppText style={{ ...typeLabel, color: active ? focusRing : textMuted }}>{label}</AppText>
|
||||||
|
{count > 0 ? (
|
||||||
|
<View style={[styles.badge, active ? styles.badgeActive : null]}>
|
||||||
|
<AppText style={{ ...typeMicro, color: active ? '#06121F' : textMuted }}>{count}</AppText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertRow({ alert, live, onPress }: { alert: Alert; live: boolean; onPress: () => void }) {
|
||||||
|
const state: AlarmState = live ? 'triggered' : alert.status;
|
||||||
|
const Icon = alert.role === 'admin' ? ShieldCheck : BellRing;
|
||||||
|
return (
|
||||||
|
<Card onPress={onPress} accessibilityLabel={`${alert.name}. Open alert detail.`}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.rowIcon, live ? { borderColor: stateAlarm } : null]}>
|
||||||
|
<Icon size={20} color={live ? stateAlarm : textMuted} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, gap: space2 }}>
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: text }} numberOfLines={1}>
|
||||||
|
{alert.name || alert.alertId}
|
||||||
|
</AppText>
|
||||||
|
<View style={styles.rowMeta}>
|
||||||
|
<StatePill state={state} />
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
{alert.role === 'admin' ? 'Owner' : 'Subscribed'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ChevronRight size={20} color={textFaint} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: bg },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space2,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingTop: space4,
|
||||||
|
paddingBottom: space3,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: minTapTarget,
|
||||||
|
height: minTapTarget,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
liveBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space2,
|
||||||
|
backgroundColor: stateAlarm,
|
||||||
|
marginHorizontal: space4,
|
||||||
|
marginBottom: space3,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingVertical: space3,
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
},
|
||||||
|
tabs: { flexDirection: 'row', gap: space2, paddingHorizontal: space4, paddingBottom: space3 },
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space2,
|
||||||
|
minHeight: minTapTarget,
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
},
|
||||||
|
tabActive: { borderColor: focusRing },
|
||||||
|
badge: {
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
backgroundColor: surface,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
badgeActive: { backgroundColor: focusRing },
|
||||||
|
content: { paddingHorizontal: space4, paddingBottom: space10 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||||
|
rowIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: border,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
||||||
|
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
|
||||||
|
footerActions: { gap: space2, paddingTop: space6 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AccessibilityInfo,
|
||||||
|
Animated,
|
||||||
|
BackHandler,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native';
|
||||||
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
|
import { useAuth } from '../state/AuthContext';
|
||||||
|
import { AppText } from '../components/ui';
|
||||||
|
import * as api from '../services/alarmApi';
|
||||||
|
import { openSoundSettings, playAlarm, stopAlarm, isAlarmNativeAvailable } from '../services/alarmNative';
|
||||||
|
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import {
|
||||||
|
motionSiren,
|
||||||
|
radiusFull,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
takeoverFlat,
|
||||||
|
onAlarm,
|
||||||
|
onAlarmDim,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeDisplay,
|
||||||
|
typeHeading,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
warn,
|
||||||
|
warnInk,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type Rt = RouteProp<RootStackParamList, 'LiveAlarm'>;
|
||||||
|
|
||||||
|
const ACK_LABEL = 'Acknowledge';
|
||||||
|
const STOP_LABEL = 'Stop for everyone';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live alarm takeover (DESIGN.md §8, `01-alarm-ux.md` §2, specs §9).
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free at render time: everything shown comes from the
|
||||||
|
* event already in hand, never from a fetch, because the contract's target is
|
||||||
|
* under a second on a LAN. There is no offline strip and no error state that
|
||||||
|
* could hide the siren — the sound is bundled and local.
|
||||||
|
*
|
||||||
|
* Visual weight is intentional and audited (D-1): **Acknowledge** is the solid
|
||||||
|
* white primary because it is the safe, reversible action ("silence this
|
||||||
|
* device"); **Stop for everyone** is an outline because it is irreversible and
|
||||||
|
* affects other people. On a screaming phone the thumb finds the biggest white
|
||||||
|
* target first, and that must never be the one that ends the alarm everywhere.
|
||||||
|
*/
|
||||||
|
export default function LiveAlarmScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { params } = useRoute<Rt>();
|
||||||
|
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } =
|
||||||
|
useAlarm();
|
||||||
|
const { username } = useAuth();
|
||||||
|
|
||||||
|
const alertId = params.alertId;
|
||||||
|
const event = liveEvent && liveEvent.alertId === alertId ? liveEvent : null;
|
||||||
|
const message = event?.message ?? params.message;
|
||||||
|
const alert =
|
||||||
|
adminAlerts.find(a => a.alertId === alertId) ?? subscribedAlerts.find(a => a.alertId === alertId);
|
||||||
|
const isOwner = !!adminAlerts.find(a => a.alertId === alertId);
|
||||||
|
const adminToken = adminTokenFor(alertId);
|
||||||
|
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
const [volumeBlocked, setVolumeBlocked] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reduceMotion, setReduceMotion] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const shake = useRef(new Animated.Value(0)).current;
|
||||||
|
const fade = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const serverTime = event?.serverTime ?? params.serverTime;
|
||||||
|
const triggeredAt = parseServerTime(serverTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribution. The alarm is always attributed to *a human*, never to "the
|
||||||
|
* system" — but the wire has no actor field (`alertView` in
|
||||||
|
* `Alarm/src/actions/alerts.go:131` and `realtime.AlarmFrame` carry none, and
|
||||||
|
* this service only ever holds the admin's bare UUID), so a subscriber cannot
|
||||||
|
* know the handle. Lead ruling 2026-10-18: when the handle is unavailable the
|
||||||
|
* line still attributes the alarm, just without a name —
|
||||||
|
* `The alert owner triggered the alarm`. The `@{handle}` form is used when the
|
||||||
|
* data genuinely exists (the owner's own view, or the day the service sends
|
||||||
|
* `owner`/`ownerUsername`), and is read opportunistically rather than assumed.
|
||||||
|
*/
|
||||||
|
const ownerHandle =
|
||||||
|
(alert as (api.Alert & { owner?: string; ownerUsername?: string }) | undefined)?.owner ||
|
||||||
|
(alert as (api.Alert & { ownerUsername?: string }) | undefined)?.ownerUsername ||
|
||||||
|
(isOwner ? username : null) ||
|
||||||
|
null;
|
||||||
|
const attribution = ownerHandle
|
||||||
|
? `@${ownerHandle} triggered the alarm`
|
||||||
|
: 'The alert owner triggered the alarm';
|
||||||
|
|
||||||
|
// Reduced motion: a 900ms stepped klaxon shake is exactly what this setting
|
||||||
|
// exists to suppress. Subscribe so a mid-alarm preference change is honoured.
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
AccessibilityInfo.isReduceMotionEnabled()
|
||||||
|
.then(enabled => mounted && setReduceMotion(enabled))
|
||||||
|
.catch(() => {});
|
||||||
|
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
sub.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The count-up ticks every second while the takeover is on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(fade, { toValue: 1, duration: 220, useNativeDriver: true }).start();
|
||||||
|
if (reduceMotion) return;
|
||||||
|
// A stepped ±8° oscillation reads as a physical klaxon; a smooth full
|
||||||
|
// rotation reads as a loading spinner (spec §2 item 1).
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.timing(shake, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: motionSiren,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => {
|
||||||
|
loop.stop();
|
||||||
|
shake.setValue(0);
|
||||||
|
};
|
||||||
|
}, [fade, shake, reduceMotion]);
|
||||||
|
|
||||||
|
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
|
||||||
|
// refuses the alarm stream, the notification fallback carries it and the
|
||||||
|
// screen says so instead of pretending.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const res = await playAlarm(alertId, message ?? '', event?.autoStopSeconds ?? 0);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
|
||||||
|
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopAlarm();
|
||||||
|
};
|
||||||
|
}, [alertId, message, event?.autoStopSeconds]);
|
||||||
|
|
||||||
|
const leave = useCallback(() => {
|
||||||
|
clearLiveEvent();
|
||||||
|
if (navigation.canGoBack()) navigation.goBack();
|
||||||
|
else navigation.navigate('Home');
|
||||||
|
}, [clearLiveEvent, navigation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge silences **this device only**. It must not leave the screen:
|
||||||
|
* the acknowledged state is the confirmation, the route to Stop has to stay
|
||||||
|
* reachable, and the alarm may still be live elsewhere (D-10).
|
||||||
|
*/
|
||||||
|
const ack = useCallback(async () => {
|
||||||
|
setAcknowledged(true);
|
||||||
|
try {
|
||||||
|
await stopAlarm();
|
||||||
|
} catch {
|
||||||
|
// Best effort: the notification fallback still applies.
|
||||||
|
}
|
||||||
|
// Once the native player is stopped this device is silent, so the live
|
||||||
|
// event no longer needs to drive a takeover — but the screen stays mounted
|
||||||
|
// in its acknowledged state until the user leaves it.
|
||||||
|
clearLiveEvent();
|
||||||
|
}, [clearLiveEvent]);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
if (!adminToken) {
|
||||||
|
setError('Only the alert owner can stop the alarm on every device.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.disarmAlert({ alertId, adminToken });
|
||||||
|
updateAlertLocal(alertId, { status: 'disarmed' });
|
||||||
|
await stopAlarm();
|
||||||
|
leave();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not stop the alarm.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [adminToken, alertId, leave, updateAlertLocal]);
|
||||||
|
|
||||||
|
// Hardware back and the back gesture are consumed: the only exits are
|
||||||
|
// Acknowledge and Stop alarm (spec, Cross-screen rules).
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway
|
||||||
|
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
|
||||||
|
// jerk the spec asks for (a smooth oscillation reads as decoration).
|
||||||
|
const rotate = shake.interpolate({
|
||||||
|
inputRange: [0, 0.499, 0.5, 1],
|
||||||
|
outputRange: ['-8deg', '-8deg', '8deg', '8deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsed = relativeSince(serverTime, now);
|
||||||
|
const elapsedLabel = elapsed ? `on your device · ${elapsed}` : 'on your device';
|
||||||
|
const absoluteLabel = triggeredAt ? `Triggered at ${absoluteLocal(triggeredAt)}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.root, { opacity: fade }]}>
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Animated.View style={reduceMotion ? undefined : { transform: [{ rotate }] }}>
|
||||||
|
<Siren size={48} color={onAlarm} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<AppText style={styles.label}>ALARM</AppText>
|
||||||
|
{/* The alert name is the headline: at the moment of maximum stress this
|
||||||
|
is the one piece of information the user needs (D-5). */}
|
||||||
|
<AppText style={styles.name} numberOfLines={2}>
|
||||||
|
{alert?.name ?? alertId}
|
||||||
|
</AppText>
|
||||||
|
<AppText style={styles.owner}>{attribution}</AppText>
|
||||||
|
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
||||||
|
{elapsedLabel}
|
||||||
|
</AppText>
|
||||||
|
{message ? <AppText style={styles.message}>{message}</AppText> : null}
|
||||||
|
{acknowledged ? (
|
||||||
|
<AppText style={styles.ackNote} accessibilityLiveRegion="polite">
|
||||||
|
Acknowledged — this device is silent. The alarm may still be live elsewhere.
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{volumeBlocked ? (
|
||||||
|
<View style={styles.volumeStrip}>
|
||||||
|
<VolumeX size={16} color={warn} />
|
||||||
|
<View style={styles.volumeCopy}>
|
||||||
|
<AppText style={styles.volumeText}>
|
||||||
|
Volume is locked by your device — turn it up now
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
openSoundSettings();
|
||||||
|
}}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Open the system volume controls"
|
||||||
|
style={styles.raiseButton}
|
||||||
|
>
|
||||||
|
<AppText style={styles.raiseLabel}>Raise volume</AppText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{!isAlarmNativeAvailable ? (
|
||||||
|
<AppText style={styles.note}>
|
||||||
|
No native alarm engine in this build — playing in-app only.
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<AppText style={{ ...typeLabel, color: onAlarm }}>{error}</AppText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* D-1: Acknowledge is the solid primary (safe, reversible). */}
|
||||||
|
<Pressable
|
||||||
|
onPress={ack}
|
||||||
|
disabled={acknowledged || busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Acknowledge the alarm on this device"
|
||||||
|
accessibilityHint="Silences this device only. The alarm keeps ringing on others."
|
||||||
|
style={[styles.ack, acknowledged || busy ? styles.disabled : null]}
|
||||||
|
>
|
||||||
|
<BellOff size={20} color={takeoverFlat} />
|
||||||
|
<AppText style={styles.ackLabel}>{acknowledged ? 'Acknowledged' : ACK_LABEL}</AppText>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Stop for everyone is owner-only and deliberately quieter than ACK. */}
|
||||||
|
{isOwner ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={stop}
|
||||||
|
disabled={busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Stop the alarm on every device"
|
||||||
|
accessibilityHint="Ends the alarm for every subscribed device."
|
||||||
|
style={[styles.stop, busy ? styles.disabled : null]}
|
||||||
|
>
|
||||||
|
<OctagonX size={20} color={onAlarm} />
|
||||||
|
<AppText style={styles.stopLabel}>{busy ? 'Stopping…' : STOP_LABEL}</AppText>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppText style={styles.footer}>
|
||||||
|
{isOwner
|
||||||
|
? 'Acknowledge silences this device only · Stop ends it for everyone'
|
||||||
|
: 'Acknowledge silences this device · only the alert owner can stop it for everyone'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
// Gradients must degrade to a flat fill; this is the documented flat value.
|
||||||
|
backgroundColor: takeoverFlat,
|
||||||
|
paddingHorizontal: space6,
|
||||||
|
paddingTop: space6,
|
||||||
|
paddingBottom: space6 + space2,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space3 },
|
||||||
|
label: { ...typeMicro, color: onAlarmDim, letterSpacing: 1.6 },
|
||||||
|
name: { ...typeDisplay, color: onAlarm, textAlign: 'center' },
|
||||||
|
owner: { ...typeHeading, color: onAlarm, textAlign: 'center' },
|
||||||
|
elapsed: { ...typeLabel, color: onAlarmDim },
|
||||||
|
message: { ...typeBodyStrong, color: onAlarm, textAlign: 'center' },
|
||||||
|
ackNote: { ...typeCaption, color: onAlarm, textAlign: 'center' },
|
||||||
|
actions: { gap: space3 },
|
||||||
|
volumeStrip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: warn,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
paddingLeft: space4,
|
||||||
|
paddingRight: space2,
|
||||||
|
paddingVertical: space2,
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
|
volumeCopy: { flex: 1 },
|
||||||
|
volumeText: { ...typeLabel, color: warn },
|
||||||
|
raiseButton: {
|
||||||
|
height: 36,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
backgroundColor: warn,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
raiseLabel: { ...typeLabel, color: warnInk },
|
||||||
|
note: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
|
||||||
|
errorBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: onAlarm,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
paddingHorizontal: space4,
|
||||||
|
paddingVertical: space3,
|
||||||
|
},
|
||||||
|
ack: {
|
||||||
|
height: 64,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
backgroundColor: onAlarm,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space2,
|
||||||
|
},
|
||||||
|
ackLabel: { ...typeBodyStrong, color: takeoverFlat },
|
||||||
|
stop: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: onAlarm,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: space2,
|
||||||
|
},
|
||||||
|
stopLabel: { ...typeBodyStrong, color: onAlarm },
|
||||||
|
disabled: { opacity: 0.4 },
|
||||||
|
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||||
|
import { useAuth } from '../state/AuthContext';
|
||||||
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
|
import {
|
||||||
|
AppText,
|
||||||
|
Button,
|
||||||
|
ErrorBanner,
|
||||||
|
Field,
|
||||||
|
OfflineStrip,
|
||||||
|
Screen,
|
||||||
|
TextInputBox,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { space4, text, textMuted, textSecondary, typeCaption, typeLabel, typeTitle } from '../theme/tokens';
|
||||||
|
|
||||||
|
type Mode = 'signin' | 'register';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const { login, register } = useAuth();
|
||||||
|
const { online, resolved } = useConnection();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>('signin');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isRegister = mode === 'register';
|
||||||
|
const canSubmit = !!username.trim() && !!password && (!isRegister || !!email.trim()) && online && !busy;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isRegister) {
|
||||||
|
await register(username.trim(), password, email.trim());
|
||||||
|
} else {
|
||||||
|
await login(username.trim(), password);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
e instanceof Error && e.message
|
||||||
|
? e.message
|
||||||
|
: isRegister
|
||||||
|
? 'Could not create the account. Check your details and try again.'
|
||||||
|
: 'Sign in failed. Check your username and password.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{!online ? <OfflineStrip /> : null}
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Screen>
|
||||||
|
<View style={{ gap: space4, paddingTop: space4 }}>
|
||||||
|
<View style={{ gap: space4, marginBottom: space4 }}>
|
||||||
|
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
|
Max-volume alerts for your devices
|
||||||
|
</AppText>
|
||||||
|
<AppText style={{ ...typeLabel, color: textMuted }}>
|
||||||
|
{isRegister ? 'Create account' : 'Sign in'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field label="Username" accessibilityLabel="Username">
|
||||||
|
<TextInputBox
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
accessibilityLabel="Username"
|
||||||
|
placeholder="your username"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Password" accessibilityLabel="Password, hidden characters">
|
||||||
|
<TextInputBox
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
accessibilityLabel="Password, hidden characters"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{isRegister ? (
|
||||||
|
<Field label="Email" accessibilityLabel="Email">
|
||||||
|
<TextInputBox
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
accessibilityLabel="Email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <ErrorBanner message={error} /> : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
busy
|
||||||
|
? isRegister
|
||||||
|
? 'Creating account…'
|
||||||
|
: 'Signing in…'
|
||||||
|
: isRegister
|
||||||
|
? 'Create account'
|
||||||
|
: 'Sign in'
|
||||||
|
}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
height={56}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isRegister ? 'Have an account? Sign in' : 'New here? Create an account'}
|
||||||
|
variant="ghost"
|
||||||
|
height={44}
|
||||||
|
onPress={() => {
|
||||||
|
setError(null);
|
||||||
|
setMode(isRegister ? 'signin' : 'register');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!online ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
|
||||||
|
{resolved
|
||||||
|
? 'The alarm service did not answer. Check Settings → Connection.'
|
||||||
|
: 'Looking for the alarm service…'}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { Copy, Info, RefreshCw, SunMedium } from 'lucide-react-native';
|
||||||
|
import Clipboard from '@react-native-clipboard/clipboard';
|
||||||
|
import { QrCanvas } from '../components/QrCanvas';
|
||||||
|
import { AppText, Button, Card, Header } from '../components/ui';
|
||||||
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
|
import * as api from '../services/alarmApi';
|
||||||
|
import { buildQrPayload, truncateToken } from '../lib/qrPayload';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import {
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
focusRing,
|
||||||
|
radiusLg,
|
||||||
|
radiusMd,
|
||||||
|
radiusXl,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
space6,
|
||||||
|
stateAlarm,
|
||||||
|
surface,
|
||||||
|
surfaceRaised,
|
||||||
|
text,
|
||||||
|
textFaint,
|
||||||
|
textSecondary,
|
||||||
|
typeBody,
|
||||||
|
typeCaption,
|
||||||
|
typeCode,
|
||||||
|
typeHeading,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
typeTitle,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type Rt = RouteProp<RootStackParamList, 'QrDisplay'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR display. The payload is built locally from the stored subscribe token so
|
||||||
|
* the screen works with no network; the server-rendered PNG is only a fallback
|
||||||
|
* for the (unlikely) case where the local render fails.
|
||||||
|
*
|
||||||
|
* Brightness is **not** raised: there is no brightness dependency in the tree,
|
||||||
|
* so the hint tells the user to raise it themselves. The spec's "brightness is
|
||||||
|
* raised" claim was removed rather than shipped as a false affordance (D-11).
|
||||||
|
*/
|
||||||
|
export default function QrDisplayScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { params } = useRoute<Rt>();
|
||||||
|
const alertId = params.alertId;
|
||||||
|
const { getAlert, subscribeTokenFor, adminTokenFor, updateAlertLocal } = useAlarm();
|
||||||
|
|
||||||
|
const alert = getAlert(alertId);
|
||||||
|
const adminToken = adminTokenFor(alertId);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [rotatedToken, setRotatedToken] = useState<string | null>(null);
|
||||||
|
const [confirmRotate, setConfirmRotate] = useState(false);
|
||||||
|
const [rotating, setRotating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const storedToken = subscribeTokenFor(alertId);
|
||||||
|
const token = rotatedToken ?? storedToken;
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() => (token ? buildQrPayload(alertId, token) : null),
|
||||||
|
[alertId, token],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
if (!payload) return;
|
||||||
|
try {
|
||||||
|
Clipboard.setString(payload);
|
||||||
|
setNotice('Alert link copied.');
|
||||||
|
} catch {
|
||||||
|
setNotice('Could not copy — long-press the code below to select it.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotating invalidates the QR/join path only: people who already joined stay
|
||||||
|
* subscribed (verified in `Alarm/src/actions/alerts.go` rotateSubscribeToken).
|
||||||
|
* Two-step confirm, same shape as the trigger, because it silently makes every
|
||||||
|
* printed code useless.
|
||||||
|
*/
|
||||||
|
const rotate = async () => {
|
||||||
|
if (!adminToken) {
|
||||||
|
setError('Only the alert owner can rotate the subscribe token.');
|
||||||
|
setConfirmRotate(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRotating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.rotateSubscribeToken({ alertId, adminToken });
|
||||||
|
setRotatedToken(res.subscribeToken);
|
||||||
|
updateAlertLocal(alertId, { subscribeToken: res.subscribeToken, qrPayload: res.qrPayload });
|
||||||
|
setNotice('Token rotated. Codes you shared before now no longer work.');
|
||||||
|
setConfirmRotate(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not rotate the token.');
|
||||||
|
} finally {
|
||||||
|
setRotating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Header title="QR code" onBack={() => navigation.goBack()} />
|
||||||
|
<View style={styles.body}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>No subscribe token here</AppText>
|
||||||
|
<AppText style={styles.para}>
|
||||||
|
This device does not hold the subscribe token for {alertId}, so it cannot show a code
|
||||||
|
that would join anyone. Open the alert from the device that created it.
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Header
|
||||||
|
title={alert?.name ?? 'Alert code'}
|
||||||
|
subtitle="Scan to subscribe this device"
|
||||||
|
onBack={() => navigation.goBack()}
|
||||||
|
/>
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.tileWrap}>
|
||||||
|
<QrCanvas
|
||||||
|
value={payload ?? ''}
|
||||||
|
size={240}
|
||||||
|
accessibilityLabel={`QR code that subscribes a device to ${alert?.name ?? alertId}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hintRow}>
|
||||||
|
<SunMedium size={16} color={textSecondary} />
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary, flex: 1 }}>
|
||||||
|
Turn your screen brightness up — this app cannot do it for you
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Or paste the code by hand</AppText>
|
||||||
|
<Pressable
|
||||||
|
onPress={copy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Copy the alert link"
|
||||||
|
style={styles.codeBox}
|
||||||
|
>
|
||||||
|
<AppText style={{ ...typeCode, color: text, flex: 1 }} numberOfLines={2}>
|
||||||
|
{payload}
|
||||||
|
</AppText>
|
||||||
|
<Copy size={16} color={textFaint} />
|
||||||
|
</Pressable>
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
Subscribe token {truncateToken(token, 12)}
|
||||||
|
</AppText>
|
||||||
|
{notice ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
|
||||||
|
{notice}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: stateAlarm }} accessibilityLiveRegion="polite">
|
||||||
|
{error}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rotation: revocability is one of the product's four promises. */}
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Rotate the token</AppText>
|
||||||
|
<AppText style={styles.para}>
|
||||||
|
Rotating makes every code you shared before stop working. Devices that already joined
|
||||||
|
stay subscribed — you would have to revoke those individually.
|
||||||
|
</AppText>
|
||||||
|
<Button
|
||||||
|
label={adminToken ? 'Rotate token' : 'Only the owner can rotate'}
|
||||||
|
variant="secondary"
|
||||||
|
icon={RefreshCw}
|
||||||
|
disabled={!adminToken || rotating}
|
||||||
|
onPress={() => setConfirmRotate(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Informational, not destructive: sharing a code is not a delete (D-24). */}
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Info size={18} color={textSecondary} />
|
||||||
|
<AppText style={{ ...typeLabel, color: text, flex: 1 }}>Keep this code private</AppText>
|
||||||
|
</View>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space2 }}>
|
||||||
|
Anyone who scans it can subscribe a device to this alert. The owner token that controls
|
||||||
|
triggering is separate and is shown only once — it cannot be recovered.
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button label="Done" variant="secondary" onPress={() => navigation.goBack()} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={confirmRotate}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setConfirmRotate(false)}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View style={styles.scrim}>
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<AppText style={{ ...typeTitle, color: text }}>Rotate the subscribe token?</AppText>
|
||||||
|
<AppText style={{ ...typeBody, color: textSecondary }}>
|
||||||
|
New devices can no longer join with the current QR. Devices already subscribed keep
|
||||||
|
working — remove them individually, or delete the alert to end it for everyone.
|
||||||
|
</AppText>
|
||||||
|
<Button
|
||||||
|
label={rotating ? 'Rotating…' : 'Rotate token'}
|
||||||
|
variant="alarm"
|
||||||
|
height={56}
|
||||||
|
disabled={rotating}
|
||||||
|
onPress={rotate}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
variant="secondary"
|
||||||
|
height={48}
|
||||||
|
onPress={() => setConfirmRotate(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: bg },
|
||||||
|
content: { padding: space4, paddingBottom: 48, gap: space4, alignItems: 'stretch' },
|
||||||
|
tileWrap: { alignItems: 'center', paddingVertical: space6 },
|
||||||
|
body: { padding: space4, gap: space3 },
|
||||||
|
para: { ...typeCaption, color: textSecondary },
|
||||||
|
hintRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space2,
|
||||||
|
paddingHorizontal: space2,
|
||||||
|
},
|
||||||
|
codeBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: space3,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
borderRadius: radiusMd,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
padding: space3,
|
||||||
|
minHeight: 48,
|
||||||
|
},
|
||||||
|
infoBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderRadius: radiusLg,
|
||||||
|
padding: space4,
|
||||||
|
},
|
||||||
|
infoRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
|
||||||
|
scrim: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(11,13,16,0.72)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderTopLeftRadius: radiusXl,
|
||||||
|
borderTopRightRadius: radiusXl,
|
||||||
|
padding: space6,
|
||||||
|
gap: space4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AccessibilityInfo,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Keyboard,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import {
|
||||||
|
Camera,
|
||||||
|
useCameraDevice,
|
||||||
|
useCameraPermission,
|
||||||
|
useObjectOutput,
|
||||||
|
} from 'react-native-vision-camera';
|
||||||
|
import { ScanLine, X } from 'lucide-react-native';
|
||||||
|
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
|
||||||
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
|
import { joinErrorMessage } from '../services/alarmApi';
|
||||||
|
import { parseQrPayload, parseManualToken, qrErrorMessage } from '../lib/qrPayload';
|
||||||
|
import { openAppSettings } from '../services/alarmNative';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import {
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
focusRing,
|
||||||
|
minTapTarget,
|
||||||
|
motionSiren,
|
||||||
|
radiusFull,
|
||||||
|
scrim,
|
||||||
|
stateIdle,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
surface,
|
||||||
|
text,
|
||||||
|
textMuted,
|
||||||
|
textSecondary,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeHeading,
|
||||||
|
typeLabel,
|
||||||
|
warn,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
/** §7.5: the scan window is 280×280 with 3px corner brackets. */
|
||||||
|
const VIEWFINDER = 280;
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type Rt = RouteProp<RootStackParamList, 'Scanner'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR scanner.
|
||||||
|
*
|
||||||
|
* The camera is `react-native-vision-camera` 5.x, which ships the object
|
||||||
|
* scanner in core (`useObjectOutput({types:['qr']})`) — there is no separate
|
||||||
|
* code-scanner plugin in v5. The manual paste field below is always available,
|
||||||
|
* including when the camera is denied or unavailable, because a subscribe token
|
||||||
|
* is a plain string.
|
||||||
|
*/
|
||||||
|
export default function ScannerScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { params } = useRoute<Rt>();
|
||||||
|
const { joinAlert, deviceId } = useAlarm();
|
||||||
|
const { hasPermission, requestPermission, canRequestPermission } = useCameraPermission();
|
||||||
|
const device = useCameraDevice('back');
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [manual, setManual] = useState('');
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
const [joinedName, setJoinedName] = useState<string | null>(null);
|
||||||
|
const handled = useRef(false);
|
||||||
|
const [reduceMotion, setReduceMotion] = useState(false);
|
||||||
|
const sweepValue = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
AccessibilityInfo.isReduceMotionEnabled()
|
||||||
|
.then(enabled => mounted && setReduceMotion(enabled))
|
||||||
|
.catch(() => {});
|
||||||
|
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
sub.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The scan line is the affordance that says the scanner is working. Under
|
||||||
|
// reduce-motion it parks in the middle instead of sweeping.
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduceMotion) {
|
||||||
|
sweepValue.setValue(0.5);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(sweepValue, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: motionSiren,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sweepValue, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: motionSiren,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [reduceMotion, sweepValue]);
|
||||||
|
|
||||||
|
const targetAlertId = params?.alertId;
|
||||||
|
|
||||||
|
const complete = useCallback(
|
||||||
|
async (alertId: string, token: string) => {
|
||||||
|
if (busy || handled.current) return;
|
||||||
|
handled.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const record = await joinAlert({
|
||||||
|
alertId,
|
||||||
|
subscribeToken: token,
|
||||||
|
deviceLabel: `Android ${deviceId?.slice(-4) ?? ''}`.trim(),
|
||||||
|
});
|
||||||
|
setJoinedName(record.name || alertId);
|
||||||
|
// Give the confirmation a beat, then return to the detail screen.
|
||||||
|
setTimeout(() => {
|
||||||
|
navigation.replace('AlertDetail', { alertId, role: 'subscriber' });
|
||||||
|
}, 700);
|
||||||
|
} catch (e) {
|
||||||
|
handled.current = false;
|
||||||
|
setError(joinErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, deviceId, joinAlert, navigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onScanned = useCallback(
|
||||||
|
(objects: readonly { type: string; value?: string }[]) => {
|
||||||
|
for (const obj of objects) {
|
||||||
|
const value = typeof obj.value === 'string' ? obj.value : undefined;
|
||||||
|
if (obj.type !== 'qr' || !value) continue;
|
||||||
|
const parsed = parseQrPayload(value);
|
||||||
|
if (parsed.ok) {
|
||||||
|
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
|
||||||
|
setError('That code is for a different alert.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
|
||||||
|
} else {
|
||||||
|
setError(qrErrorMessage(parsed.error));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[complete, targetAlertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// v5 ships the scanner in core: `useObjectOutput` is the code-scanner — there
|
||||||
|
// is no separate vision-camera-code-scanner plugin for 5.x.
|
||||||
|
const objectOutput = useObjectOutput({
|
||||||
|
types: ['qr'],
|
||||||
|
onObjectsScanned: onScanned,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitManual = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
const trimmed = manual.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
// Accept a full link, a scheme link, or a bare token when we know the alert.
|
||||||
|
const parsed = parseQrPayload(trimmed);
|
||||||
|
if (parsed.ok) {
|
||||||
|
await complete(parsed.payload.alertId, parsed.payload.subscribeToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A bare token can only be resolved when we arrived here from an alert.
|
||||||
|
if (targetAlertId) {
|
||||||
|
const byToken = parseManualToken(trimmed, targetAlertId);
|
||||||
|
if (byToken.ok) {
|
||||||
|
await complete(byToken.payload.alertId, byToken.payload.subscribeToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(qrErrorMessage(parsed.error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sweep = sweepValue.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-VIEWFINDER / 2, VIEWFINDER / 2],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Header
|
||||||
|
title="Scan alert code"
|
||||||
|
subtitle="Point the camera at the QR code the owner shows"
|
||||||
|
onBack={() => navigation.goBack()}
|
||||||
|
right={
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close scanner"
|
||||||
|
style={styles.iconButton}
|
||||||
|
>
|
||||||
|
<X size={20} color={textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.cameraWrap}>
|
||||||
|
{hasPermission && device ? (
|
||||||
|
<Camera
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
device={device}
|
||||||
|
isActive={!joinedName}
|
||||||
|
outputs={[objectOutput]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.permission}>
|
||||||
|
<ScanLine size={40} color={textMuted} />
|
||||||
|
<AppText style={{ ...typeHeading, color: text, marginTop: space3 }}>
|
||||||
|
Camera access is off
|
||||||
|
</AppText>
|
||||||
|
<AppText style={styles.para}>
|
||||||
|
The camera is only used to read alert codes. You can also paste the code by hand.
|
||||||
|
</AppText>
|
||||||
|
{canRequestPermission ? (
|
||||||
|
<Button label="Allow camera" onPress={requestPermission} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AppText style={styles.para}>
|
||||||
|
Camera permission was denied. Turn it on in Android Settings → Apps → Alarm →
|
||||||
|
Permissions.
|
||||||
|
</AppText>
|
||||||
|
<Button label="Open settings" variant="secondary" onPress={() => { openAppSettings(); }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button label="Enter code by hand" variant="outlineWhite" onPress={() => setShowManual(true)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission && !joinedName ? (
|
||||||
|
<View pointerEvents="none" style={styles.reticleWrap}>
|
||||||
|
<View style={styles.viewfinder}>
|
||||||
|
{/* Four 3px corner brackets: "aim inside this frame" without a
|
||||||
|
box that fights the camera image (DESIGN.md §7.5). */}
|
||||||
|
<View style={[styles.bracket, styles.bracketTL]} />
|
||||||
|
<View style={[styles.bracket, styles.bracketTR]} />
|
||||||
|
<View style={[styles.bracket, styles.bracketBL]} />
|
||||||
|
<View style={[styles.bracket, styles.bracketBR]} />
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.scanLine,
|
||||||
|
reduceMotion ? styles.scanLineRest : { transform: [{ translateY: sweep }] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<AppText style={styles.reticleHint}>Keep the whole code inside the frame</AppText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{joinedName ? (
|
||||||
|
<View style={styles.joined}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Subscribed</AppText>
|
||||||
|
<AppText style={styles.para}>This device will ring for {joinedName}.</AppText>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
{error ? <ErrorBanner message={error} /> : null}
|
||||||
|
{showManual || !hasPermission || !device ? (
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<Field label="Alert code or link">
|
||||||
|
<TextInputBox
|
||||||
|
value={manual}
|
||||||
|
onChangeText={setManual}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
placeholder="https://alarm.dcntrlized.org/a/…#token"
|
||||||
|
accessibilityLabel="Alert code or link"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
label={busy ? 'Subscribing…' : 'Subscribe this device'}
|
||||||
|
onPress={submitManual}
|
||||||
|
disabled={busy || !manual.trim()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Button label="Enter code by hand" variant="secondary" onPress={() => setShowManual(true)} />
|
||||||
|
)}
|
||||||
|
{targetAlertId ? (
|
||||||
|
<AppText style={styles.target}>Subscribing to {targetAlertId}</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: bg },
|
||||||
|
iconButton: {
|
||||||
|
width: minTapTarget,
|
||||||
|
height: minTapTarget,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cameraWrap: { flex: 1, backgroundColor: '#000000', justifyContent: 'center' },
|
||||||
|
permission: { padding: space4, alignItems: 'center', gap: space2 },
|
||||||
|
para: { ...typeCaption, color: textSecondary, textAlign: 'center', marginBottom: space2 },
|
||||||
|
reticleWrap: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
viewfinder: { width: VIEWFINDER, height: VIEWFINDER, justifyContent: 'center' },
|
||||||
|
bracket: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderColor: stateIdle,
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
bracketTL: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
|
||||||
|
bracketTR: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
|
||||||
|
bracketBL: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
|
||||||
|
bracketBR: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
|
||||||
|
scanLine: {
|
||||||
|
height: 2,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
backgroundColor: focusRing,
|
||||||
|
},
|
||||||
|
scanLineRest: { position: 'absolute', top: '50%', left: 0, right: 0 },
|
||||||
|
reticleHint: {
|
||||||
|
...typeCaption,
|
||||||
|
color: text,
|
||||||
|
marginTop: space3,
|
||||||
|
backgroundColor: scrim,
|
||||||
|
paddingHorizontal: space3,
|
||||||
|
paddingVertical: space2,
|
||||||
|
borderRadius: radiusFull,
|
||||||
|
},
|
||||||
|
joined: { alignItems: 'center', gap: space2, backgroundColor: scrim, padding: space4 },
|
||||||
|
sheet: {
|
||||||
|
padding: space4,
|
||||||
|
gap: space3,
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: border,
|
||||||
|
},
|
||||||
|
target: { ...typeLabel, color: textMuted, textAlign: 'center' },
|
||||||
|
manualHint: { ...typeBodyStrong, color: warn },
|
||||||
|
cameraIcon: { ...typeLabel, color: text },
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { Info, RefreshCw, Server, Smartphone, Wifi, WifiOff } from 'lucide-react-native';
|
||||||
|
import { useAuth } from '../state/AuthContext';
|
||||||
|
import { useAlarm } from '../state/AlarmContext';
|
||||||
|
import { useConnection } from '../state/ConnectionContext';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
isAlarmNativeAvailable,
|
||||||
|
openExactAlarmSettings,
|
||||||
|
openFullScreenIntentSettings,
|
||||||
|
requestPermission,
|
||||||
|
startForegroundService,
|
||||||
|
stopForegroundService,
|
||||||
|
type AlarmPermissions,
|
||||||
|
} from '../services/alarmNative';
|
||||||
|
import { alarmSocketUrl } from '../services/connection';
|
||||||
|
import { AppText, Button, Card, Divider, HealthIcon, Header, OfflineStrip } from '../components/ui';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
import {
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
focusRing,
|
||||||
|
minTapTarget,
|
||||||
|
radiusMd,
|
||||||
|
space2,
|
||||||
|
space3,
|
||||||
|
space4,
|
||||||
|
stateDisarmed,
|
||||||
|
surfaceRaised,
|
||||||
|
text,
|
||||||
|
textFaint,
|
||||||
|
textMuted,
|
||||||
|
textSecondary,
|
||||||
|
typeBodyStrong,
|
||||||
|
typeCaption,
|
||||||
|
typeCode,
|
||||||
|
typeHeading,
|
||||||
|
typeLabel,
|
||||||
|
typeMicro,
|
||||||
|
warn,
|
||||||
|
} from '../theme/tokens';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
|
||||||
|
notifications: {
|
||||||
|
title: 'Notifications',
|
||||||
|
ok: 'Allowed — alerts can post a high-priority notification.',
|
||||||
|
bad: 'Not allowed — a trigger cannot notify you while the app is closed.',
|
||||||
|
},
|
||||||
|
fullScreenIntent: {
|
||||||
|
title: 'Full-screen alarm',
|
||||||
|
ok: 'Allowed — the takeover can cover the lock screen.',
|
||||||
|
bad: 'Not allowed — the alarm will only show as a notification when locked.',
|
||||||
|
},
|
||||||
|
exactAlarm: {
|
||||||
|
title: 'Exact alarms',
|
||||||
|
ok: 'Allowed — auto-stop and scheduled work fire on time.',
|
||||||
|
bad: 'Not allowed — Android may delay auto-stop.',
|
||||||
|
},
|
||||||
|
batteryUnrestricted: {
|
||||||
|
title: 'Battery optimisation',
|
||||||
|
ok: 'Unrestricted — the armed socket keeps running in the background.',
|
||||||
|
bad: 'Optimised — Android may freeze the app and drop the armed socket.',
|
||||||
|
},
|
||||||
|
canForceVolume: {
|
||||||
|
title: 'Alarm volume',
|
||||||
|
ok: 'The alarm stream can be raised to maximum automatically.',
|
||||||
|
bad: 'The OS blocks forced volume — you will get a notification instead.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { username, email, logout } = useAuth();
|
||||||
|
const { resolved, settings, updateSettings, reprobe, probing, online, isRelease } = useConnection();
|
||||||
|
const { activeAlertId, delivery, socketState, reconnectAttempts, subscribedAlerts } = useAlarm();
|
||||||
|
|
||||||
|
const [perms, setPerms] = useState<AlarmPermissions | null>(null);
|
||||||
|
const [serviceRunning, setServiceRunning] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshPerms = useCallback(async () => {
|
||||||
|
setPerms(await getPermissions());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPerms();
|
||||||
|
}, [refreshPerms]);
|
||||||
|
|
||||||
|
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
|
||||||
|
const wsUrl = resolved && activeAlert
|
||||||
|
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const toggleService = async () => {
|
||||||
|
if (!activeAlert || !wsUrl) {
|
||||||
|
setNotice('Subscribe to an alert first — the foreground service holds that alert’s socket.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (serviceRunning) {
|
||||||
|
await stopForegroundService();
|
||||||
|
setServiceRunning(false);
|
||||||
|
setNotice('Foreground service stopped. The socket only lives while the app is open.');
|
||||||
|
} else {
|
||||||
|
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
|
||||||
|
setServiceRunning(res.ok);
|
||||||
|
setNotice(
|
||||||
|
res.ok
|
||||||
|
? 'Foreground service running — the armed socket survives backgrounding.'
|
||||||
|
: `Could not start the service (${res.reason ?? 'unknown'}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
{!online ? <OfflineStrip /> : null}
|
||||||
|
<Header title="Settings & health" onBack={() => navigation.goBack()} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
{/* ── Delivery ─────────────────────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
|
||||||
|
<Row
|
||||||
|
icon={delivery === 'socket' ? Wifi : WifiOff}
|
||||||
|
tint={delivery === 'socket' ? stateDisarmed : warn}
|
||||||
|
title={
|
||||||
|
delivery === 'socket'
|
||||||
|
? `Connected (${socketState})`
|
||||||
|
: 'Unavailable in proxy mode'
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
delivery === 'socket'
|
||||||
|
? 'Triggers arrive over the service WebSocket; the target is under a second on a LAN.'
|
||||||
|
: 'Maestro has no service WebSocket passthrough, so status is polled every 5 seconds while the app is open and every 30 seconds in the background.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{delivery === 'socket' && reconnectAttempts > 0 ? (
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
Reconnect attempt {reconnectAttempts} — backing off up to 30s.
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
<Row
|
||||||
|
icon={Smartphone}
|
||||||
|
tint={isAlarmNativeAvailable ? stateDisarmed : warn}
|
||||||
|
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
|
||||||
|
detail={
|
||||||
|
isAlarmNativeAvailable
|
||||||
|
? 'Max-volume STREAM_ALARM playback, vibration and the lock-screen takeover are wired.'
|
||||||
|
: 'This build has no native alarm module (expected off-Android); alerts fall back to an in-app sound.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={toggleService}
|
||||||
|
/>
|
||||||
|
{notice ? (
|
||||||
|
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
|
||||||
|
{notice}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Permission health checklist ──────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<View style={styles.headRow}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Permissions</AppText>
|
||||||
|
<Pressable
|
||||||
|
onPress={refreshPerms}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Re-check permissions"
|
||||||
|
style={styles.iconButton}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} color={textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{perms ? (
|
||||||
|
<>
|
||||||
|
<HealthRow
|
||||||
|
ok={perms.notifications}
|
||||||
|
onFix={() => requestPermission('notifications').then(setPerms)}
|
||||||
|
okText={HEALTH_COPY.notifications.ok}
|
||||||
|
title={HEALTH_COPY.notifications.title}
|
||||||
|
bad={HEALTH_COPY.notifications.bad}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
ok={perms.fullScreenIntent}
|
||||||
|
onFix={() =>
|
||||||
|
openFullScreenIntentSettings().then(() =>
|
||||||
|
requestPermission('fullScreenIntent').then(setPerms),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
okText={HEALTH_COPY.fullScreenIntent.ok}
|
||||||
|
title={HEALTH_COPY.fullScreenIntent.title}
|
||||||
|
bad={HEALTH_COPY.fullScreenIntent.bad}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
ok={perms.exactAlarm}
|
||||||
|
onFix={() =>
|
||||||
|
openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
|
||||||
|
}
|
||||||
|
okText={HEALTH_COPY.exactAlarm.ok}
|
||||||
|
title={HEALTH_COPY.exactAlarm.title}
|
||||||
|
bad={HEALTH_COPY.exactAlarm.bad}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
ok={perms.batteryUnrestricted}
|
||||||
|
onFix={() => requestPermission('battery').then(setPerms)}
|
||||||
|
okText={HEALTH_COPY.batteryUnrestricted.ok}
|
||||||
|
title={HEALTH_COPY.batteryUnrestricted.title}
|
||||||
|
bad={HEALTH_COPY.batteryUnrestricted.bad}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
ok={perms.canForceVolume}
|
||||||
|
onFix={undefined}
|
||||||
|
okText={HEALTH_COPY.canForceVolume.ok}
|
||||||
|
title={HEALTH_COPY.canForceVolume.title}
|
||||||
|
bad={HEALTH_COPY.canForceVolume.bad}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||||
|
Reading permission state…
|
||||||
|
</AppText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Connection ───────────────────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Connection</AppText>
|
||||||
|
<Row
|
||||||
|
icon={Server}
|
||||||
|
tint={online ? stateDisarmed : warn}
|
||||||
|
title={online ? `Reachable — ${settings.mode} mode` : 'No host answered'}
|
||||||
|
detail={resolved ? resolved.baseUrl : 'Run a probe to look for the service.'}
|
||||||
|
/>
|
||||||
|
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
|
||||||
|
<View style={styles.modeRow}>
|
||||||
|
{(['maestro', 'direct'] as const).map(mode => (
|
||||||
|
<Pressable
|
||||||
|
key={mode}
|
||||||
|
onPress={() => updateSettings({ mode })}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected: settings.mode === mode }}
|
||||||
|
accessibilityLabel={mode === 'maestro' ? 'Maestro proxy mode' : 'Direct service mode'}
|
||||||
|
style={[styles.modeChip, settings.mode === mode ? styles.modeChipActive : null]}
|
||||||
|
>
|
||||||
|
<AppText
|
||||||
|
style={{
|
||||||
|
...typeLabel,
|
||||||
|
color: settings.mode === mode ? focusRing : textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'maestro' ? 'Maestro proxy' : 'Direct'}
|
||||||
|
</AppText>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
Direct mode is the only mode with live WebSocket delivery. Proxy mode polls.
|
||||||
|
</AppText>
|
||||||
|
<Button label={probing ? 'Probing…' : 'Re-run connection probe'} variant="secondary" onPress={reprobe} />
|
||||||
|
{!isRelease ? (
|
||||||
|
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||||
|
Dev build · host {settings.host || '(auto)'} : {settings.port || '(default)'}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Account ──────────────────────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<View style={{ gap: space3 }}>
|
||||||
|
<AppText style={{ ...typeHeading, color: text }}>Account</AppText>
|
||||||
|
<Row
|
||||||
|
icon={Info}
|
||||||
|
tint={textMuted}
|
||||||
|
title={username ?? 'Not signed in'}
|
||||||
|
detail={email || 'Signed in on this instance'}
|
||||||
|
/>
|
||||||
|
<Button label="Sign out" variant="secondary" onPress={logout} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
|
||||||
|
Alarm client · Dcntrlized · AGPL-3.0
|
||||||
|
</AppText>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
icon: Icon,
|
||||||
|
tint,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
icon: typeof Wifi;
|
||||||
|
tint: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Icon size={20} color={tint} />
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<AppText style={{ ...typeBodyStrong, color: text }}>{title}</AppText>
|
||||||
|
<AppText style={{ ...typeCaption, color: textSecondary }}>{detail}</AppText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthRow({
|
||||||
|
ok,
|
||||||
|
title,
|
||||||
|
okText,
|
||||||
|
bad,
|
||||||
|
onFix,
|
||||||
|
}: {
|
||||||
|
ok: boolean;
|
||||||
|
title: string;
|
||||||
|
okText: string;
|
||||||
|
bad: string;
|
||||||
|
onFix?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.healthRow}>
|
||||||
|
<HealthIcon status={ok ? 'ok' : 'warn'} />
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<AppText style={{ ...typeLabel, color: text }}>{title}</AppText>
|
||||||
|
<AppText style={{ ...typeCaption, color: ok ? textSecondary : warn }}>
|
||||||
|
{ok ? okText : bad}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
{!ok && onFix ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onFix}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Fix ${title}`}
|
||||||
|
style={styles.fixButton}
|
||||||
|
>
|
||||||
|
<AppText style={{ ...typeLabel, color: focusRing }}>Fix</AppText>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: bg },
|
||||||
|
content: { padding: space4, paddingBottom: 48, gap: space4 },
|
||||||
|
headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
iconButton: {
|
||||||
|
width: minTapTarget,
|
||||||
|
height: minTapTarget,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
row: { flexDirection: 'row', alignItems: 'flex-start', gap: space3 },
|
||||||
|
healthRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||||
|
fixButton: {
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: space3,
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radiusMd,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focusRing,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
...typeCode,
|
||||||
|
color: textMuted,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
borderRadius: radiusMd,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
padding: space3,
|
||||||
|
},
|
||||||
|
modeRow: { flexDirection: 'row', gap: space2 },
|
||||||
|
modeChip: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radiusMd,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
backgroundColor: surfaceRaised,
|
||||||
|
},
|
||||||
|
modeChipActive: { borderColor: focusRing },
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Alarm service API — the frozen contract, client side.
|
||||||
|
*
|
||||||
|
* `/action/<name>` calls go through `apiClient`'s `{payload: …}` envelope and
|
||||||
|
* carry `X-Access-Token`; `/auth/*` calls pass `raw = true` so the body is the
|
||||||
|
* plain JSON object (root AGENTS.md body contract).
|
||||||
|
*
|
||||||
|
* Actions: createAlert, getAlert, joinAlert, listSubscriptions,
|
||||||
|
* removeSubscription, triggerAlert, disarmAlert, alarmStatus, deleteAlert,
|
||||||
|
* qrImage.
|
||||||
|
*/
|
||||||
|
import { ApiError, del, get, post, requestBase64 } from './apiClient';
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
export interface AuthUser {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user?: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alarm client identifies with a username (DESIGN.md §1 uses `Username`),
|
||||||
|
* so the username is sent in the identity field the auth hub accepts. The hub
|
||||||
|
* resolves either form.
|
||||||
|
*/
|
||||||
|
export function login(payload: { username: string; password: string }): Promise<AuthResponse> {
|
||||||
|
return post<AuthResponse>('/auth/login', { email: payload.username, password: payload.password }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(payload: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email: string;
|
||||||
|
}): Promise<AuthResponse> {
|
||||||
|
return post<AuthResponse>('/auth/register', payload, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(refreshToken: string): Promise<unknown> {
|
||||||
|
return post<unknown>('/auth/logout', { refreshToken }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function me(): Promise<MeResponse> {
|
||||||
|
return get<MeResponse>('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alerts ───────────────────────────────────────────────────────────────────
|
||||||
|
export type AlertStatus = 'idle' | 'armed' | 'triggered' | 'disarmed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last event the service recorded for an alert (`alertView.lastEvent`). The
|
||||||
|
* client uses it to label *when* the alarm last rang or was disarmed, because
|
||||||
|
* there is no arm/enablement gate to describe instead.
|
||||||
|
*/
|
||||||
|
export interface AlertEvent {
|
||||||
|
eventId?: string;
|
||||||
|
action?: 'trigger' | 'disarm';
|
||||||
|
message?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
alertId: string;
|
||||||
|
name: string;
|
||||||
|
message?: string;
|
||||||
|
status: AlertStatus;
|
||||||
|
/** True while the alarm is ringing right now (server-computed, auto-stop aware). */
|
||||||
|
active?: boolean;
|
||||||
|
/** Bumped by every ``rotateSubscribeToken``. */
|
||||||
|
tokenVersion?: number;
|
||||||
|
modified?: string;
|
||||||
|
triggeredAt?: string;
|
||||||
|
disarmedAt?: string;
|
||||||
|
lastEvent?: AlertEvent;
|
||||||
|
/** Owner handle, when the service starts sending it. No actor field exists today. */
|
||||||
|
owner?: string;
|
||||||
|
ownerUsername?: string;
|
||||||
|
/** Owner-only. Never rendered in full (DESIGN.md §7.4 truncates to 12 + …). */
|
||||||
|
adminToken?: string;
|
||||||
|
subscribeToken?: string;
|
||||||
|
/** `https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}` */
|
||||||
|
qrPayload?: string;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
created?: string;
|
||||||
|
/** Client-side only: which local role this alert is held in. */
|
||||||
|
role?: 'admin' | 'subscriber';
|
||||||
|
/** Client-side only: subscription id for the subscriber role. */
|
||||||
|
subscriptionId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
lastTriggered?: string;
|
||||||
|
subscriberCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
subscriptionId: string;
|
||||||
|
alertId?: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
platform?: string;
|
||||||
|
joined?: string;
|
||||||
|
lastSeen?: string;
|
||||||
|
/** True when this device reported it could not use full volume. */
|
||||||
|
volumeBlocked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmStatus {
|
||||||
|
active: boolean;
|
||||||
|
eventId?: string;
|
||||||
|
action?: 'trigger' | 'disarm';
|
||||||
|
message?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAlert(payload: {
|
||||||
|
name: string;
|
||||||
|
message?: string;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
}): Promise<Alert> {
|
||||||
|
return post<Alert>('/action/createAlert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlert(alertId: string): Promise<Alert> {
|
||||||
|
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinAlert(payload: {
|
||||||
|
alertId: string;
|
||||||
|
subscribeToken: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
platform?: string;
|
||||||
|
}): Promise<Subscription & { alert?: Alert }> {
|
||||||
|
return post<Subscription & { alert?: Alert }>('/action/joinAlert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSubscriptions(alertId: string): Promise<Subscription[]> {
|
||||||
|
return get<Subscription[]>(`/action/listSubscriptions?alertId=${encodeURIComponent(alertId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSubscription(payload: {
|
||||||
|
alertId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return post<unknown>('/action/removeSubscription', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerAlert(payload: {
|
||||||
|
alertId: string;
|
||||||
|
adminToken: string;
|
||||||
|
message?: string;
|
||||||
|
}): Promise<AlarmStatus & { eventId?: string }> {
|
||||||
|
return post<AlarmStatus & { eventId?: string }>('/action/triggerAlert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disarmAlert(payload: {
|
||||||
|
alertId: string;
|
||||||
|
adminToken: string;
|
||||||
|
}): Promise<AlarmStatus> {
|
||||||
|
return post<AlarmStatus>('/action/disarmAlert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polling fallback + catch-up, valid in both connection modes. */
|
||||||
|
export function alarmStatus(alertId: string): Promise<AlarmStatus> {
|
||||||
|
return get<AlarmStatus>(`/action/alarmStatus?alertId=${encodeURIComponent(alertId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
|
||||||
|
return del<unknown>('/action/deleteAlert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional convenience: the service renders the PNG; resolved as base64. */
|
||||||
|
export function qrImage(alertId: string): Promise<string> {
|
||||||
|
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an `alarmStatus` response onto the four-state ramp. `alarmStatus` reports
|
||||||
|
* `active` + the last `action`, which is exactly enough to derive the state:
|
||||||
|
* active → triggered, last action disarm → disarmed, otherwise idle.
|
||||||
|
*/
|
||||||
|
export interface RotateResponse {
|
||||||
|
alertId: string;
|
||||||
|
subscribeToken: string;
|
||||||
|
qrPayload: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates the subscribe token. Existing subscriptions survive — rotation
|
||||||
|
* invalidates the QR/join path, not the people who already joined — so a leaked
|
||||||
|
* code can be retired without kicking anyone off.
|
||||||
|
*/
|
||||||
|
export async function rotateSubscribeToken(input: {
|
||||||
|
alertId: string;
|
||||||
|
adminToken: string;
|
||||||
|
}): Promise<RotateResponse> {
|
||||||
|
return post<RotateResponse>('rotateSubscribeToken', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy for a failed join, keyed by the service's error code.
|
||||||
|
*
|
||||||
|
* `JoinAlert` answers `ERR_ALERT_TOKEN_INVALID` for a token that was rotated
|
||||||
|
* **and** for one that was never valid — the service deliberately does not
|
||||||
|
* distinguish them, so the client cannot observe which happened and must not
|
||||||
|
* claim a mechanism. "This code is no longer valid — ask the owner for a new
|
||||||
|
* one." is true of both cases and tells the user what to do next; "expired"
|
||||||
|
* would be a guess.
|
||||||
|
*/
|
||||||
|
export function joinErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.code === 'ERR_ALERT_TOKEN_INVALID') {
|
||||||
|
return 'This code is no longer valid — ask the owner for a new one.';
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return 'Could not subscribe this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusToState(status: AlarmStatus | null | undefined): AlertStatus {
|
||||||
|
if (!status) return 'idle';
|
||||||
|
if (status.active) return 'triggered';
|
||||||
|
if (status.action === 'disarm') return 'disarmed';
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAutoStop(seconds: number | undefined): string {
|
||||||
|
if (!seconds || seconds <= 0) return 'until stopped';
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { NativeModules, Platform } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS bridge to the Android alarm engine (`com.dcntrlized.alarm.AlarmModule`).
|
||||||
|
*
|
||||||
|
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
|
||||||
|
* the native module failed to register, the promise resolves to a "unavailable"
|
||||||
|
* result instead of throwing. The UI must degrade to in-app sound + a
|
||||||
|
* high-priority notification, and say so, rather than break.
|
||||||
|
*/
|
||||||
|
export interface AlarmNativeResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Android only: whether the OS actually let us take the alarm stream. */
|
||||||
|
volumeRaised?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alert a native full-screen launch was for. `AlarmActivity` carries these
|
||||||
|
* extras, so a cold start from a trigger still lands on the right takeover.
|
||||||
|
*/
|
||||||
|
export interface AlarmLaunchAlert {
|
||||||
|
alertId: string;
|
||||||
|
message: string;
|
||||||
|
autoStopSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmPermissions {
|
||||||
|
notifications: boolean;
|
||||||
|
fullScreenIntent: boolean;
|
||||||
|
exactAlarm: boolean;
|
||||||
|
batteryUnrestricted: boolean;
|
||||||
|
/** True when the app may play at full alarm volume without a system prompt. */
|
||||||
|
canForceVolume: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmNativeModule {
|
||||||
|
playAlarm(alertId: string, message: string, autoStopSeconds: number): Promise<AlarmNativeResult>;
|
||||||
|
stopAlarm(): Promise<AlarmNativeResult>;
|
||||||
|
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||||
|
stopService(): Promise<AlarmNativeResult>;
|
||||||
|
getPermissions(): Promise<AlarmPermissions>;
|
||||||
|
requestPermission(name: string): Promise<AlarmPermissions>;
|
||||||
|
getLaunchAlert(): Promise<AlarmLaunchAlert | null>;
|
||||||
|
isIgnoringBatteryOptimizations(): Promise<boolean>;
|
||||||
|
openSoundSettings(): Promise<AlarmNativeResult>;
|
||||||
|
openAppSettings(): Promise<AlarmNativeResult>;
|
||||||
|
openFullScreenIntentSettings(): Promise<AlarmNativeResult>;
|
||||||
|
openExactAlarmSettings(): Promise<AlarmNativeResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const native = (NativeModules as Record<string, AlarmNativeModule | undefined>).AlarmModule;
|
||||||
|
|
||||||
|
export const isAlarmNativeAvailable = Platform.OS === 'android' && !!native;
|
||||||
|
|
||||||
|
const UNAVAILABLE: AlarmNativeResult = { ok: false, reason: 'native-module-unavailable' };
|
||||||
|
|
||||||
|
const PERMISSIONS_UNAVAILABLE: AlarmPermissions = {
|
||||||
|
notifications: false,
|
||||||
|
fullScreenIntent: false,
|
||||||
|
exactAlarm: false,
|
||||||
|
batteryUnrestricted: false,
|
||||||
|
canForceVolume: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function playAlarm(
|
||||||
|
alertId: string,
|
||||||
|
message = '',
|
||||||
|
autoStopSeconds = 0,
|
||||||
|
): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.playAlarm(alertId, message, autoStopSeconds);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopAlarm(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.stopAlarm();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e instanceof Error ? e.message : 'stop-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the socket alive while armed: a foreground service with a persistent
|
||||||
|
* notification, so the trigger still lands when the app is backgrounded.
|
||||||
|
*/
|
||||||
|
export async function startForegroundService(
|
||||||
|
alertId: string,
|
||||||
|
subscribeToken: string,
|
||||||
|
wsUrl: string,
|
||||||
|
): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.startService(alertId, subscribeToken, wsUrl);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e instanceof Error ? e.message : 'service-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopForegroundService(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.stopService();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e instanceof Error ? e.message : 'service-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads (and consumes) the launch intent, so a trigger that starts the process
|
||||||
|
* from cold still opens the takeover. Resolves `null` when this was an ordinary
|
||||||
|
* app launch.
|
||||||
|
*/
|
||||||
|
export async function getLaunchAlert(): Promise<AlarmLaunchAlert | null> {
|
||||||
|
if (!native) return null;
|
||||||
|
try {
|
||||||
|
const result = await native.getLaunchAlert();
|
||||||
|
return result && result.alertId ? result : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissions(): Promise<AlarmPermissions> {
|
||||||
|
if (!native) return PERMISSIONS_UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.getPermissions();
|
||||||
|
} catch {
|
||||||
|
return PERMISSIONS_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `name` is one of: notifications | fullScreenIntent | exactAlarm | battery. */
|
||||||
|
export async function requestPermission(name: string): Promise<AlarmPermissions> {
|
||||||
|
if (!native) return PERMISSIONS_UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.requestPermission(name);
|
||||||
|
} catch {
|
||||||
|
return PERMISSIONS_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
|
||||||
|
export async function openSoundSettings(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.openSoundSettings();
|
||||||
|
} catch {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens this app's Android settings page — the camera-denied recovery action. */
|
||||||
|
export async function openAppSettings(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.openAppSettings();
|
||||||
|
} catch {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFullScreenIntentSettings(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.openFullScreenIntentSettings();
|
||||||
|
} catch {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openExactAlarmSettings(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.openExactAlarmSettings();
|
||||||
|
} catch {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Alarm WebSocket client.
|
||||||
|
*
|
||||||
|
* URL (frozen contract, direct mode only — Maestro has no service WS
|
||||||
|
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||||
|
*
|
||||||
|
* Frames:
|
||||||
|
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
|
||||||
|
* "message","serverTime","autoStopSeconds"}
|
||||||
|
* client → server {"type":"ack","eventId"}
|
||||||
|
* client → server {"type":"ping"}
|
||||||
|
*
|
||||||
|
* The reconnect policy and the frame parser are pure and exported separately so
|
||||||
|
* they can be tested headlessly (no React Native, no real socket) — see
|
||||||
|
* `__tests__/alarmSocket.test.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SocketState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||||
|
|
||||||
|
export interface ReconnectPolicy {
|
||||||
|
/** First backoff step in ms. */
|
||||||
|
baseMs: number;
|
||||||
|
/** Backoff ceiling in ms. */
|
||||||
|
maxMs: number;
|
||||||
|
/** Multiplier applied per attempt. */
|
||||||
|
factor: number;
|
||||||
|
/** Random ± fraction applied to each delay (0..1). */
|
||||||
|
jitter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = {
|
||||||
|
baseMs: 1000,
|
||||||
|
maxMs: 30000,
|
||||||
|
factor: 2,
|
||||||
|
jitter: 0.25,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure exponential-backoff state machine.
|
||||||
|
*
|
||||||
|
* 1s → 2s → 4s → 8s → 16s → 30s (cap), each step multiplied by a jitter factor
|
||||||
|
* in `[1-jitter, 1+jitter]`. `onOpened()` resets the attempt counter, so a
|
||||||
|
* healthy connection always restarts the ramp from `baseMs`.
|
||||||
|
*/
|
||||||
|
export class ReconnectMachine {
|
||||||
|
private readonly policy: ReconnectPolicy;
|
||||||
|
private readonly random: () => number;
|
||||||
|
private attempts = 0;
|
||||||
|
private state: SocketState = 'idle';
|
||||||
|
|
||||||
|
constructor(policy: Partial<ReconnectPolicy> = {}, random: () => number = Math.random) {
|
||||||
|
this.policy = { ...DEFAULT_RECONNECT_POLICY, ...policy };
|
||||||
|
this.random = random;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): SocketState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttempts(): number {
|
||||||
|
return this.attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnecting(): void {
|
||||||
|
this.state = this.attempts === 0 ? 'connecting' : 'reconnecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A successful open resets the ramp. */
|
||||||
|
onOpened(): void {
|
||||||
|
this.attempts = 0;
|
||||||
|
this.state = 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a drop and return the delay to wait before the next attempt.
|
||||||
|
* The counter increments even when the delay is capped, so the state stays
|
||||||
|
* monotonic; only the returned delay saturates.
|
||||||
|
*/
|
||||||
|
onClosed(): number {
|
||||||
|
this.attempts += 1;
|
||||||
|
this.state = 'reconnecting';
|
||||||
|
return this.delayFor(this.attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosedPermanently(): void {
|
||||||
|
this.state = 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic ramp (jitter applied) for a 1-based attempt number. */
|
||||||
|
delayFor(attempt: number): number {
|
||||||
|
const { baseMs, maxMs, factor, jitter } = this.policy;
|
||||||
|
const raw = baseMs * Math.pow(factor, Math.max(0, attempt - 1));
|
||||||
|
const capped = Math.min(raw, maxMs);
|
||||||
|
if (jitter <= 0) return Math.round(capped);
|
||||||
|
const spread = capped * jitter;
|
||||||
|
const value = capped - spread + this.random() * spread * 2;
|
||||||
|
return Math.max(0, Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The un-jittered ramp, for tests and for the settings screen. */
|
||||||
|
nominalDelayFor(attempt: number): number {
|
||||||
|
const { baseMs, maxMs, factor } = this.policy;
|
||||||
|
return Math.min(baseMs * Math.pow(factor, Math.max(0, attempt - 1)), maxMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frames ───────────────────────────────────────────────────────────────────
|
||||||
|
export interface AlarmFrame {
|
||||||
|
type: 'alarm';
|
||||||
|
alertId: string;
|
||||||
|
eventId: string;
|
||||||
|
action: 'trigger' | 'disarm';
|
||||||
|
message?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AckFrame {
|
||||||
|
type: 'ack';
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingFrame {
|
||||||
|
type: 'ping';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingFrame = AlarmFrame | AckFrame | PingFrame | { type: 'unknown' };
|
||||||
|
|
||||||
|
/** Parse one server frame. Never throws; unknown/garbage becomes `{type:'unknown'}`. */
|
||||||
|
export function parseFrame(raw: string): IncomingFrame {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { type: 'unknown' };
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') return { type: 'unknown' };
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const type = obj.type;
|
||||||
|
if (type === 'alarm') {
|
||||||
|
if (typeof obj.alertId !== 'string' || typeof obj.eventId !== 'string') {
|
||||||
|
return { type: 'unknown' };
|
||||||
|
}
|
||||||
|
if (obj.action !== 'trigger' && obj.action !== 'disarm') {
|
||||||
|
return { type: 'unknown' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'alarm',
|
||||||
|
alertId: obj.alertId,
|
||||||
|
eventId: obj.eventId,
|
||||||
|
action: obj.action,
|
||||||
|
message: typeof obj.message === 'string' ? obj.message : undefined,
|
||||||
|
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
|
||||||
|
autoStopSeconds:
|
||||||
|
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'ack') {
|
||||||
|
if (typeof obj.eventId !== 'string') return { type: 'unknown' };
|
||||||
|
return { type: 'ack', eventId: obj.eventId };
|
||||||
|
}
|
||||||
|
if (type === 'ping') return { type: 'ping' };
|
||||||
|
return { type: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAck(eventId: string): string {
|
||||||
|
return JSON.stringify({ type: 'ack', eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPing(): string {
|
||||||
|
return JSON.stringify({ type: 'ping' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Socket wrapper ───────────────────────────────────────────────────────────
|
||||||
|
export interface AlarmSocketOptions {
|
||||||
|
url: string;
|
||||||
|
/** Called for every valid `alarm` frame. */
|
||||||
|
onAlarm: (frame: AlarmFrame) => void;
|
||||||
|
onStateChange?: (state: SocketState, attempt: number) => void;
|
||||||
|
/** Heartbeat interval; 0 disables. */
|
||||||
|
pingIntervalMs?: number;
|
||||||
|
policy?: Partial<ReconnectPolicy>;
|
||||||
|
/** Injectable for tests. */
|
||||||
|
socketFactory?: (url: string) => WebSocket;
|
||||||
|
random?: () => number;
|
||||||
|
now?: () => number;
|
||||||
|
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin reconnecting wrapper. Deliberately dependency-free apart from the global
|
||||||
|
* `WebSocket` (injectable) so the same code runs under jest.
|
||||||
|
*/
|
||||||
|
export class AlarmSocket {
|
||||||
|
private readonly opts: Required<
|
||||||
|
Pick<AlarmSocketOptions, 'url' | 'onAlarm' | 'pingIntervalMs'>
|
||||||
|
> &
|
||||||
|
AlarmSocketOptions;
|
||||||
|
private readonly machine: ReconnectMachine;
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private closedByUser = false;
|
||||||
|
|
||||||
|
constructor(options: AlarmSocketOptions) {
|
||||||
|
this.opts = { pingIntervalMs: 25000, ...options };
|
||||||
|
this.machine = new ReconnectMachine(options.policy ?? {}, options.random ?? Math.random);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): SocketState {
|
||||||
|
return this.machine.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.closedByUser = false;
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): void {
|
||||||
|
this.opts.onStateChange?.(this.machine.getState(), this.machine.getAttempts());
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(): void {
|
||||||
|
this.machine.onConnecting();
|
||||||
|
this.emitState();
|
||||||
|
const factory =
|
||||||
|
this.opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocket);
|
||||||
|
let ws: WebSocket;
|
||||||
|
try {
|
||||||
|
ws = factory(this.opts.url);
|
||||||
|
} catch {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ws = ws;
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.machine.onOpened();
|
||||||
|
this.emitState();
|
||||||
|
this.startPing();
|
||||||
|
};
|
||||||
|
ws.onmessage = event => {
|
||||||
|
const data = (event as { data?: unknown }).data;
|
||||||
|
if (typeof data !== 'string') return;
|
||||||
|
const frame = parseFrame(data);
|
||||||
|
if (frame.type === 'alarm') {
|
||||||
|
this.opts.onAlarm(frame);
|
||||||
|
this.ack(frame.eventId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
// `onclose` always follows; nothing to do here.
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.stopPing();
|
||||||
|
this.ws = null;
|
||||||
|
if (this.closedByUser) {
|
||||||
|
this.machine.onClosedPermanently();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
const delay = this.machine.onClosed();
|
||||||
|
this.emitState();
|
||||||
|
const setTimer = this.opts.setTimer ?? setTimeout;
|
||||||
|
this.reconnectTimer = setTimer(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
if (!this.closedByUser) this.open();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPing(): void {
|
||||||
|
const interval = this.opts.pingIntervalMs;
|
||||||
|
if (!interval) return;
|
||||||
|
this.stopPing();
|
||||||
|
this.pingTimer = setInterval(() => this.send(buildPing()), interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPing(): void {
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ack(eventId: string): void {
|
||||||
|
this.send(buildAck(eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(payload: string): boolean {
|
||||||
|
if (!this.ws || this.machine.getState() !== 'open') return false;
|
||||||
|
try {
|
||||||
|
this.ws.send(payload);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closedByUser = true;
|
||||||
|
this.stopPing();
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
(this.opts.clearTimer ?? clearTimeout)(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
const ws = this.ws;
|
||||||
|
this.ws = null;
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this.machine.onClosedPermanently();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* HTTP client for the Alarm service — house pattern from
|
||||||
|
* `calendar-client/src/services/apiClient.ts`, retargeted:
|
||||||
|
*
|
||||||
|
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}`
|
||||||
|
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only),
|
||||||
|
* - direct mode addresses the service's own listener and uses the `{payload: …}`
|
||||||
|
* envelope for `/action/*` while `/auth/*` take raw JSON bodies
|
||||||
|
* (`raw = true`),
|
||||||
|
* - every call carries `X-Access-Token` when a session exists,
|
||||||
|
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight
|
||||||
|
* `/auth/refresh` and a single retry; a failed refresh clears the session.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number | null;
|
||||||
|
/**
|
||||||
|
* The service's `error.code` (e.g. `ERR_ALERT_TOKEN_INVALID`) when the body
|
||||||
|
* carried one. Errors are disambiguated by code, never by message text: the
|
||||||
|
* same code can mean several things to the user (see `joinErrorMessage`).
|
||||||
|
*/
|
||||||
|
code: string | null;
|
||||||
|
constructor(status: number | null, message: string, code: string | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
mode: 'maestro' | 'direct';
|
||||||
|
serviceRef: string | null;
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
onUnauthorized: () => void;
|
||||||
|
/** Called after a successful token refresh so the session can be persisted. */
|
||||||
|
onSessionUpdate: (token: string, refreshToken: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: ApiConfig = {
|
||||||
|
baseUrl: '',
|
||||||
|
mode: 'direct',
|
||||||
|
serviceRef: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
onUnauthorized: () => {},
|
||||||
|
onSessionUpdate: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function configureApi(patch: Partial<ApiConfig>): void {
|
||||||
|
Object.assign(config, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiConfig(): Readonly<ApiConfig> {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitQuery(path: string): [string, string] {
|
||||||
|
const i = path.indexOf('?');
|
||||||
|
return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string): string {
|
||||||
|
if (config.mode === 'maestro') {
|
||||||
|
if (!config.serviceRef) {
|
||||||
|
throw new ApiError(null, 'Service not found through Maestro');
|
||||||
|
}
|
||||||
|
const [pathOnly, query] = splitQuery(path);
|
||||||
|
const segments = pathOnly.split('/').filter(Boolean);
|
||||||
|
const actionName = segments[segments.length - 1];
|
||||||
|
if (!actionName) throw new ApiError(null, `Invalid path: ${path}`);
|
||||||
|
let url = `${config.baseUrl}/api/actions/${encodeURIComponent(config.serviceRef)}/${actionName}`;
|
||||||
|
if (query) url += `?${query}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `${config.baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One in-flight refresh shared by every 401: a burst of requests that all hit
|
||||||
|
// an expired token triggers a single POST /auth/refresh and every waiter
|
||||||
|
// resolves with the same result.
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function refreshSession(): Promise<boolean> {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
if (!config.refreshToken) return false;
|
||||||
|
const url = buildUrl('/auth/refresh');
|
||||||
|
// Raw {refreshToken} in direct mode; {payload} action form in Maestro
|
||||||
|
// mode — the service proxies both (raw flag contract, root AGENTS.md).
|
||||||
|
const body =
|
||||||
|
config.mode === 'maestro'
|
||||||
|
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
|
||||||
|
: JSON.stringify({ refreshToken: config.refreshToken });
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data.accessToken as string | undefined;
|
||||||
|
if (!token) return false;
|
||||||
|
config.token = token;
|
||||||
|
config.refreshToken = (data.refreshToken as string | undefined) ?? config.refreshToken;
|
||||||
|
config.onSessionUpdate(token, config.refreshToken);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BodyMode = 'json' | 'text' | 'base64';
|
||||||
|
|
||||||
|
async function perform<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
payload: unknown,
|
||||||
|
raw: boolean,
|
||||||
|
bodyMode: BodyMode,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!config.baseUrl) {
|
||||||
|
throw new ApiError(null, 'Server not configured yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Maestro mode every call goes through /api/actions/{service}/{action},
|
||||||
|
// which always expects the { payload: ... } wrapper. The raw flag only
|
||||||
|
// applies when talking to a service directly.
|
||||||
|
const maestro = config.mode === 'maestro';
|
||||||
|
let body: string | undefined;
|
||||||
|
if (payload !== undefined) {
|
||||||
|
body = maestro || !raw ? JSON.stringify({ payload }) : JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(path);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (config.token) {
|
||||||
|
headers['X-Access-Token'] = config.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { method, headers, body });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('apiClient fetch failed', method, path, url, e);
|
||||||
|
throw new ApiError(
|
||||||
|
null,
|
||||||
|
`Cannot reach the server (${(e as Error).name}: ${(e as Error).message})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
if (bodyMode === 'text') return (await res.text()) as T;
|
||||||
|
if (bodyMode === 'base64') return (await responseToBase64(res)) as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = `HTTP ${res.status}`;
|
||||||
|
let code: string | null = null;
|
||||||
|
try {
|
||||||
|
const t = await res.text();
|
||||||
|
if (t) {
|
||||||
|
message = t;
|
||||||
|
// Services answer `{"error":{"code":…, "message":…}}`. Rendering that
|
||||||
|
// raw body put JSON in front of the user and made every caller guess at
|
||||||
|
// the shape — unwrap it here, once, and keep the code for the callers
|
||||||
|
// that need to distinguish cases the message cannot.
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
|
||||||
|
const err = parsed?.error;
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
if (typeof err.code === 'string') code = err.code;
|
||||||
|
if (typeof err.message === 'string' && err.message) message = err.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — keep the text as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// A 401, or any body reporting an invalid/expired token, means the session
|
||||||
|
// needs a refresh. This only applies when we actually hold a token — a 401
|
||||||
|
// on /auth/login just means wrong credentials and must surface the
|
||||||
|
// server's message instead.
|
||||||
|
const sessionExpired =
|
||||||
|
config.token !== null &&
|
||||||
|
(res.status === 401 ||
|
||||||
|
code === 'ERR_UNAUTHENTICATED' ||
|
||||||
|
code === 'ERR_TOKEN_EXPIRED' ||
|
||||||
|
code === 'ERR_SESSION_REVOKED' ||
|
||||||
|
/token has invalid claims/.test(message) ||
|
||||||
|
/token.*expired/i.test(message));
|
||||||
|
if (!sessionExpired) {
|
||||||
|
throw new ApiError(res.status, message, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First failure: try one refresh (single-flight). A successful refresh
|
||||||
|
// rotates the tokens and the original call is retried once; a failed
|
||||||
|
// refresh always clears the session — never loop.
|
||||||
|
if (attempt === 0 && (await refreshSession())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
config.onUnauthorized();
|
||||||
|
throw new ApiError(res.status, 'Session expired');
|
||||||
|
}
|
||||||
|
throw new ApiError(null, 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `qrImage` returns image/png. RN's fetch has no reliable `blob()` on Android,
|
||||||
|
* so the PNG is read through a FileReader as a data URL and the base64 part is
|
||||||
|
* returned — `react-native-svg`'s `Image` accepts `data:image/png;base64,…`.
|
||||||
|
*/
|
||||||
|
async function responseToBase64(res: Response): Promise<string> {
|
||||||
|
const blob = await res.blob();
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new ApiError(res.status, 'Could not read image response'));
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = String(reader.result ?? '');
|
||||||
|
const comma = result.indexOf(',');
|
||||||
|
resolve(comma === -1 ? result : result.slice(comma + 1));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
payload?: unknown,
|
||||||
|
raw = false,
|
||||||
|
): Promise<T> {
|
||||||
|
return perform<T>(method, path, payload, raw, 'json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text response variant for endpoints that are not JSON. */
|
||||||
|
export async function requestText(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return perform<string>(method, path, undefined, false, 'text');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary (PNG) response variant, resolved as base64 (see `responseToBase64`). */
|
||||||
|
export async function requestBase64(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<string> {
|
||||||
|
return perform<string>(method, path, payload, false, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get<T>(path: string): Promise<T> {
|
||||||
|
return request<T>('GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
|
||||||
|
return request<T>('POST', path, payload, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
|
return request<T>('PUT', path, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
|
||||||
|
return request<T>('DELETE', path, payload);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Connection resolution for the Alarm client.
|
||||||
|
*
|
||||||
|
* Mirrors `calendar-client/src/services/connection.ts` (the house pattern):
|
||||||
|
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and
|
||||||
|
* the service is addressed through `serviceRef`) and `direct` (the service's own
|
||||||
|
* `/_manifest` probe). Ports are unique per client/service so several can run at
|
||||||
|
* once. The Alarm service owns dev host port **8088**; this client's Metro runs
|
||||||
|
* on **8089** (lead ruling, 2026-10-18). They must differ: both are reached over
|
||||||
|
* `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev machine's*
|
||||||
|
* `127.0.0.1:<port>`, so a shared number would make the packager and the service
|
||||||
|
* compete for one host socket. Per-client Metro ports: keepnote=8082,
|
||||||
|
* hub=8084, calendar=8086, alarm=8089.
|
||||||
|
*
|
||||||
|
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket
|
||||||
|
* passthrough — its only WS is `/api/ws` for its own container-status stream and
|
||||||
|
* its action surface is HTTP-only. So live delivery over the socket is a
|
||||||
|
* direct-mode capability; in maestro mode the client polls `alarmStatus`.
|
||||||
|
*/
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export type ConnectionMode = 'maestro' | 'direct';
|
||||||
|
|
||||||
|
export interface ConnectionSettings {
|
||||||
|
mode: ConnectionMode;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
useAdbReverse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_NAME = 'alarm';
|
||||||
|
|
||||||
|
export const isRelease = !__DEV__;
|
||||||
|
|
||||||
|
/** Dev machine LAN address (fallback when adb reverse is off). */
|
||||||
|
const LAN_HOST = '192.168.1.232';
|
||||||
|
|
||||||
|
export const DEFAULTS: Record<ConnectionMode, { host: string; port: string }> = {
|
||||||
|
maestro: { host: LAN_HOST, port: '8080' },
|
||||||
|
direct: { host: LAN_HOST, port: '8088' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: ConnectionSettings = {
|
||||||
|
mode: 'maestro',
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
useAdbReverse: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'settings.connection';
|
||||||
|
const LAST_HOST_KEY = 'settings.lastHost';
|
||||||
|
|
||||||
|
export function defaultPort(mode: ConnectionMode): string {
|
||||||
|
return DEFAULTS[mode].port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseBaseUrl(): string {
|
||||||
|
return `https://${SERVICE_NAME}.dcntrlized.org`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<ConnectionSettings> {
|
||||||
|
if (isRelease) {
|
||||||
|
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ConnectionSettings>) };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
|
||||||
|
if (isRelease) return;
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateHosts(settings: ConnectionSettings, lastHost: string | null): string[] {
|
||||||
|
const explicit = settings.host.trim();
|
||||||
|
if (explicit) return [explicit];
|
||||||
|
const hosts: string[] = [];
|
||||||
|
if (lastHost) hosts.push(lastHost);
|
||||||
|
hosts.push(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
|
||||||
|
if (settings.useAdbReverse) hosts.push(LAN_HOST);
|
||||||
|
return [...new Set(hosts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reachable(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
const res = await fetch(`${baseUrl}/_manifest`, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaestroContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
service_name?: string;
|
||||||
|
docker_name?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findServiceRef(list: MaestroContainer[]): string | null {
|
||||||
|
const needle = SERVICE_NAME.toLowerCase();
|
||||||
|
const candidates = list.filter(c => {
|
||||||
|
const svc = (c.service_name || '').toLowerCase();
|
||||||
|
const docker = (c.docker_name || '').toLowerCase();
|
||||||
|
return svc === needle || docker === needle;
|
||||||
|
});
|
||||||
|
const match = candidates.find(c => c.status === 'running') || candidates[0];
|
||||||
|
if (!match) return null;
|
||||||
|
return match.service_name || match.docker_name || match.name || match.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maestro does not serve /_manifest; its reachability check and the service
|
||||||
|
// container lookup both come from the /api/containers endpoint.
|
||||||
|
async function probeMaestro(
|
||||||
|
baseUrl: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<{ reachable: boolean; serviceRef: string | null }> {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
const res = await fetch(`${baseUrl}/api/containers`, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) return { reachable: false, serviceRef: null };
|
||||||
|
const list = (await res.json()) as MaestroContainer[];
|
||||||
|
return { reachable: true, serviceRef: findServiceRef(list) };
|
||||||
|
} catch (e) {
|
||||||
|
console.log('probeMaestro fetch failed', baseUrl, (e as Error).name, (e as Error).message);
|
||||||
|
return { reachable: false, serviceRef: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedConnection {
|
||||||
|
baseUrl: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
adbReverse: string | null;
|
||||||
|
mode: ConnectionMode;
|
||||||
|
serviceRef: string | null;
|
||||||
|
/** True when a live WebSocket is possible (direct mode only — see header). */
|
||||||
|
socketSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probe(
|
||||||
|
settings: ConnectionSettings,
|
||||||
|
timeoutMs = 2500,
|
||||||
|
): Promise<ResolvedConnection> {
|
||||||
|
if (isRelease) {
|
||||||
|
const host = `${SERVICE_NAME}.dcntrlized.org`;
|
||||||
|
return {
|
||||||
|
baseUrl: releaseBaseUrl(),
|
||||||
|
host,
|
||||||
|
port: '443',
|
||||||
|
adbReverse: null,
|
||||||
|
mode: 'direct',
|
||||||
|
serviceRef: null,
|
||||||
|
socketSupported: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = settings.port.trim() || defaultPort(settings.mode);
|
||||||
|
let lastHost: string | null = null;
|
||||||
|
try {
|
||||||
|
lastHost = await AsyncStorage.getItem(LAST_HOST_KEY);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const hosts = candidateHosts(settings, lastHost);
|
||||||
|
for (const host of hosts) {
|
||||||
|
const baseUrl = `http://${host}:${port}`;
|
||||||
|
if (settings.mode === 'maestro') {
|
||||||
|
const { reachable: okReach, serviceRef } = await probeMaestro(baseUrl, timeoutMs);
|
||||||
|
if (okReach) {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(LAST_HOST_KEY, host);
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
adbReverse: adbReverseCommand(settings, port, host),
|
||||||
|
mode: settings.mode,
|
||||||
|
serviceRef,
|
||||||
|
socketSupported: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (await reachable(baseUrl, timeoutMs)) {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(LAST_HOST_KEY, host);
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
adbReverse: adbReverseCommand(settings, port, host),
|
||||||
|
mode: settings.mode,
|
||||||
|
serviceRef: null,
|
||||||
|
socketSupported: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hosts[0];
|
||||||
|
return {
|
||||||
|
baseUrl: `http://${host}:${port}`,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
adbReverse: adbReverseCommand(settings, port, host),
|
||||||
|
mode: settings.mode,
|
||||||
|
serviceRef: null,
|
||||||
|
socketSupported: settings.mode === 'direct',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adbReverseCommand(
|
||||||
|
settings: ConnectionSettings,
|
||||||
|
port?: string,
|
||||||
|
host?: string,
|
||||||
|
): string | null {
|
||||||
|
if (isRelease) return null;
|
||||||
|
const p = port || settings.port.trim() || defaultPort(settings.mode);
|
||||||
|
const h =
|
||||||
|
host ||
|
||||||
|
settings.host.trim() ||
|
||||||
|
(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
|
||||||
|
if (h !== '127.0.0.1') return null;
|
||||||
|
return `adb reverse tcp:${p} tcp:${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact WebSocket URL the client opens for an alert. Direct mode only:
|
||||||
|
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
|
||||||
|
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
|
||||||
|
*
|
||||||
|
* Returned verbatim here so it is testable headlessly and so the service team
|
||||||
|
* can diff the client's expectation against the service.
|
||||||
|
*/
|
||||||
|
export function alarmSocketUrl(
|
||||||
|
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>,
|
||||||
|
alertId: string,
|
||||||
|
subscribeToken: string,
|
||||||
|
): string | null {
|
||||||
|
if (resolved.mode !== 'direct') return null;
|
||||||
|
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
||||||
|
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
|
||||||
|
return `${ws}/ws/alarm?${q}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as api from '../services/alarmApi';
|
||||||
|
import type { Alert, AlertStatus, Subscription } from '../services/alarmApi';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import { useConnection } from './ConnectionContext';
|
||||||
|
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
|
||||||
|
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
|
||||||
|
|
||||||
|
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
|
||||||
|
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
||||||
|
|
||||||
|
export interface LiveAlarmEvent {
|
||||||
|
alertId: string;
|
||||||
|
eventId?: string;
|
||||||
|
action: 'trigger' | 'disarm';
|
||||||
|
message?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
autoStopSeconds?: number;
|
||||||
|
/** True when the event came in over the socket rather than a poll. */
|
||||||
|
live: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmContextValue {
|
||||||
|
/** Alerts this device created (holds the admin token). */
|
||||||
|
adminAlerts: Alert[];
|
||||||
|
/** Alerts this device joined (holds the subscribe token). */
|
||||||
|
subscribedAlerts: Alert[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** The alert whose socket/poll loop is currently active. */
|
||||||
|
activeAlertId: string | null;
|
||||||
|
setActiveAlertId: (alertId: string | null) => void;
|
||||||
|
socketState: string;
|
||||||
|
delivery: LiveDelivery;
|
||||||
|
reconnectAttempts: number;
|
||||||
|
liveEvent: LiveAlarmEvent | null;
|
||||||
|
/** Cleared when the takeover is acknowledged/dismissed. */
|
||||||
|
clearLiveEvent: () => void;
|
||||||
|
lastStatus: api.AlarmStatus | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
createAlert: (input: { name: string; message?: string; autoStopSeconds?: number }) => Promise<Alert>;
|
||||||
|
joinAlert: (input: {
|
||||||
|
alertId: string;
|
||||||
|
subscribeToken: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
}) => Promise<Alert>;
|
||||||
|
leaveAlert: (alertId: string) => Promise<void>;
|
||||||
|
forgetAdminAlert: (alertId: string) => Promise<void>;
|
||||||
|
updateAlertLocal: (alertId: string, patch: Partial<Alert>) => void;
|
||||||
|
getAlert: (alertId: string) => Alert | undefined;
|
||||||
|
adminTokenFor: (alertId: string) => string | null;
|
||||||
|
subscribeTokenFor: (alertId: string) => string | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlarmContext = createContext<AlarmContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
async function readJson<T>(key: string, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(key);
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(key: string, value: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Persistence is best-effort; the in-memory list stays authoritative for
|
||||||
|
// the session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { resolved, online } = useConnection();
|
||||||
|
|
||||||
|
const [adminAlerts, setAdminAlerts] = useState<Alert[]>([]);
|
||||||
|
const [subscribedAlerts, setSubscribedAlerts] = useState<Alert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeAlertId, setActiveAlertId] = useState<string | null>(null);
|
||||||
|
const [liveEvent, setLiveEvent] = useState<LiveAlarmEvent | null>(null);
|
||||||
|
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// A per-install device id: stable across launches, unique per device.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
let id = await AsyncStorage.getItem('alarm.deviceId.v1');
|
||||||
|
if (!id) {
|
||||||
|
id = `dev_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
||||||
|
await AsyncStorage.setItem('alarm.deviceId.v1', id);
|
||||||
|
}
|
||||||
|
if (!cancelled) setDeviceId(id);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [admins, subs] = await Promise.all([
|
||||||
|
readJson<Alert[]>(ADMIN_ALERTS_KEY, []),
|
||||||
|
readJson<Alert[]>(SUBSCRIBED_ALERTS_KEY, []),
|
||||||
|
]);
|
||||||
|
setAdminAlerts(admins);
|
||||||
|
setSubscribedAlerts(subs);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not load your alerts.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
// Default the live loop to the first subscribed alert once data lands.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAlertId) return;
|
||||||
|
const first = subscribedAlerts[0];
|
||||||
|
if (first) setActiveAlertId(first.alertId);
|
||||||
|
}, [subscribedAlerts, activeAlertId]);
|
||||||
|
|
||||||
|
const activeAlert = useMemo(
|
||||||
|
() => subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null,
|
||||||
|
[subscribedAlerts, activeAlertId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrigger = useCallback(
|
||||||
|
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string; autoStopSeconds?: number }, live: boolean) => {
|
||||||
|
const event: LiveAlarmEvent = {
|
||||||
|
alertId: frame.alertId,
|
||||||
|
eventId: frame.eventId,
|
||||||
|
action: 'trigger',
|
||||||
|
message: frame.message,
|
||||||
|
serverTime: frame.serverTime,
|
||||||
|
autoStopSeconds: frame.autoStopSeconds,
|
||||||
|
live,
|
||||||
|
};
|
||||||
|
setLiveEvent(event);
|
||||||
|
setSubscribedAlerts(prev =>
|
||||||
|
prev.map(a =>
|
||||||
|
a.alertId === frame.alertId
|
||||||
|
? { ...a, status: 'triggered' as AlertStatus, lastTriggered: frame.serverTime }
|
||||||
|
: a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
openLiveAlarm({
|
||||||
|
alertId: frame.alertId,
|
||||||
|
eventId: frame.eventId,
|
||||||
|
message: frame.message,
|
||||||
|
serverTime: frame.serverTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisarm = useCallback(
|
||||||
|
(frame: { alertId: string; eventId?: string; serverTime?: string }, live: boolean) => {
|
||||||
|
setSubscribedAlerts(prev =>
|
||||||
|
prev.map(a =>
|
||||||
|
a.alertId === frame.alertId ? { ...a, status: 'disarmed' as AlertStatus } : a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setLiveEvent(prev => {
|
||||||
|
if (!prev || prev.alertId !== frame.alertId) return prev;
|
||||||
|
return { ...prev, action: 'disarm', eventId: frame.eventId, serverTime: frame.serverTime, live };
|
||||||
|
});
|
||||||
|
closeLiveAlarm();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { socketState, delivery, attempts, lastStatus, refresh } = useAlarmSocket({
|
||||||
|
resolved,
|
||||||
|
alertId: activeAlert?.alertId ?? null,
|
||||||
|
subscribeToken: activeAlert?.subscribeToken ?? null,
|
||||||
|
enabled: !!token && online,
|
||||||
|
onTrigger: frame => handleTrigger(frame, true),
|
||||||
|
onDisarm: frame => handleDisarm(frame, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll results are a safety net: if the server says "active" and we have no
|
||||||
|
// live event for it, raise the takeover from the polled data (never gated on
|
||||||
|
// a fetch — the takeover renders from what is already in hand).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastStatus || !activeAlert) return;
|
||||||
|
if (lastStatus.active && lastStatus.action === 'trigger') {
|
||||||
|
if (liveEvent?.alertId === activeAlert.alertId && liveEvent.action === 'trigger') return;
|
||||||
|
handleTrigger(
|
||||||
|
{
|
||||||
|
alertId: activeAlert.alertId,
|
||||||
|
eventId: lastStatus.eventId,
|
||||||
|
message: lastStatus.message,
|
||||||
|
serverTime: lastStatus.serverTime,
|
||||||
|
autoStopSeconds: lastStatus.autoStopSeconds,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else if (!lastStatus.active && liveEvent?.alertId === activeAlert.alertId) {
|
||||||
|
setLiveEvent(prev => (prev ? { ...prev, action: 'disarm' } : prev));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lastStatus, activeAlert?.alertId]);
|
||||||
|
|
||||||
|
const createAlert = useCallback(
|
||||||
|
async (input: { name: string; message?: string; autoStopSeconds?: number }) => {
|
||||||
|
const created = await api.createAlert(input);
|
||||||
|
const record: Alert = { ...created, role: 'admin', status: created.status ?? 'armed' };
|
||||||
|
setAdminAlerts(prev => {
|
||||||
|
const next = [record, ...prev.filter(a => a.alertId !== record.alertId)];
|
||||||
|
writeJson(ADMIN_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinAlert = useCallback(
|
||||||
|
async (input: { alertId: string; subscribeToken: string; deviceLabel?: string }) => {
|
||||||
|
if (!deviceId) throw new Error('This device is still starting up. Try again in a moment.');
|
||||||
|
const joined = await api.joinAlert({
|
||||||
|
alertId: input.alertId,
|
||||||
|
subscribeToken: input.subscribeToken,
|
||||||
|
deviceId,
|
||||||
|
deviceLabel: input.deviceLabel,
|
||||||
|
platform: 'android',
|
||||||
|
});
|
||||||
|
const record: Alert = {
|
||||||
|
...(joined.alert ?? { alertId: input.alertId, name: input.alertId, status: 'armed' as AlertStatus }),
|
||||||
|
alertId: input.alertId,
|
||||||
|
subscribeToken: input.subscribeToken,
|
||||||
|
role: 'subscriber',
|
||||||
|
subscriptionId: joined.subscriptionId,
|
||||||
|
deviceId,
|
||||||
|
deviceLabel: input.deviceLabel,
|
||||||
|
status: (joined.alert?.status as AlertStatus) ?? 'armed',
|
||||||
|
};
|
||||||
|
setSubscribedAlerts(prev => {
|
||||||
|
const next = [record, ...prev.filter(a => a.alertId !== record.alertId)];
|
||||||
|
writeJson(SUBSCRIBED_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActiveAlertId(record.alertId);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
[deviceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveAlert = useCallback(
|
||||||
|
async (alertId: string) => {
|
||||||
|
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
||||||
|
if (target?.subscriptionId) {
|
||||||
|
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
|
||||||
|
}
|
||||||
|
setSubscribedAlerts(prev => {
|
||||||
|
const next = prev.filter(a => a.alertId !== alertId);
|
||||||
|
writeJson(SUBSCRIBED_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActiveAlertId(prev => (prev === alertId ? null : prev));
|
||||||
|
},
|
||||||
|
[subscribedAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const forgetAdminAlert = useCallback(async (alertId: string) => {
|
||||||
|
setAdminAlerts(prev => {
|
||||||
|
const next = prev.filter(a => a.alertId !== alertId);
|
||||||
|
writeJson(ADMIN_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAlertLocal = useCallback((alertId: string, patch: Partial<Alert>) => {
|
||||||
|
setAdminAlerts(prev => {
|
||||||
|
const next = prev.map(a => (a.alertId === alertId ? { ...a, ...patch } : a));
|
||||||
|
writeJson(ADMIN_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSubscribedAlerts(prev => {
|
||||||
|
const next = prev.map(a => (a.alertId === alertId ? { ...a, ...patch } : a));
|
||||||
|
writeJson(SUBSCRIBED_ALERTS_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getAlert = useCallback(
|
||||||
|
(alertId: string) =>
|
||||||
|
adminAlerts.find(a => a.alertId === alertId) ?? subscribedAlerts.find(a => a.alertId === alertId),
|
||||||
|
[adminAlerts, subscribedAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminTokenFor = useCallback(
|
||||||
|
(alertId: string) => adminAlerts.find(a => a.alertId === alertId)?.adminToken ?? null,
|
||||||
|
[adminAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribeTokenFor = useCallback(
|
||||||
|
(alertId: string) =>
|
||||||
|
subscribedAlerts.find(a => a.alertId === alertId)?.subscribeToken ??
|
||||||
|
adminAlerts.find(a => a.alertId === alertId)?.subscribeToken ??
|
||||||
|
null,
|
||||||
|
[adminAlerts, subscribedAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: AlarmContextValue = {
|
||||||
|
adminAlerts,
|
||||||
|
subscribedAlerts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activeAlertId,
|
||||||
|
setActiveAlertId,
|
||||||
|
socketState,
|
||||||
|
delivery,
|
||||||
|
reconnectAttempts: attempts,
|
||||||
|
liveEvent,
|
||||||
|
clearLiveEvent: () => setLiveEvent(null),
|
||||||
|
lastStatus,
|
||||||
|
refresh,
|
||||||
|
reload,
|
||||||
|
createAlert,
|
||||||
|
joinAlert,
|
||||||
|
leaveAlert,
|
||||||
|
forgetAdminAlert,
|
||||||
|
updateAlertLocal,
|
||||||
|
getAlert,
|
||||||
|
adminTokenFor,
|
||||||
|
subscribeTokenFor,
|
||||||
|
deviceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AlarmContext.Provider value={value}>{children}</AlarmContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlarm(): AlarmContextValue {
|
||||||
|
const ctx = useContext(AlarmContext);
|
||||||
|
if (!ctx) throw new Error('useAlarm must be used within AlarmProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: a subscription list refresh keyed to an alert. */
|
||||||
|
export function useSubscriptions(alertId: string | null, enabled = true) {
|
||||||
|
const [subs, setSubs] = useState<Subscription[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!alertId || !enabled) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const list = await api.listSubscriptions(alertId);
|
||||||
|
if (mounted.current) setSubs(Array.isArray(list) ? list : []);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted.current) setError(e instanceof Error ? e.message : 'Could not load devices.');
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [alertId, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
return { subs, loading, error, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { configureApi } from '../services/apiClient';
|
||||||
|
import * as api from '../services/alarmApi';
|
||||||
|
|
||||||
|
const SESSION_KEY = 'auth.session';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
booting: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, password: string, email: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [booting, setBooting] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const persist = useCallback(async (s: Session) => {
|
||||||
|
setSession(s);
|
||||||
|
configureApi({ token: s.token, refreshToken: s.refreshToken });
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(s));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// persistTokens is invoked by the apiClient after a successful refresh so the
|
||||||
|
// rotated tokens survive an app restart.
|
||||||
|
const persistTokens = useCallback((token: string, refreshToken: string) => {
|
||||||
|
configureApi({ token, refreshToken });
|
||||||
|
setSession((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = { ...prev, token, refreshToken };
|
||||||
|
AsyncStorage.setItem(SESSION_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setSession(null);
|
||||||
|
configureApi({ token: null, refreshToken: null });
|
||||||
|
AsyncStorage.removeItem(SESSION_KEY).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configureApi({ onUnauthorized: clearSession });
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
||||||
|
if (!cancelled && raw) {
|
||||||
|
const s = JSON.parse(raw) as Session;
|
||||||
|
setSession(s);
|
||||||
|
configureApi({ token: s.token, refreshToken: s.refreshToken });
|
||||||
|
// Boot revalidation (optimistic, non-blocking): hydrate the stored
|
||||||
|
// username/email from the server. If the access token expired, the
|
||||||
|
// apiClient's single-flight refresh rotates it and this retries; if
|
||||||
|
// the refresh fails the session is cleared.
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then((me) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = {
|
||||||
|
...s,
|
||||||
|
username: me.username ?? s.username,
|
||||||
|
email: me.email ?? s.email,
|
||||||
|
};
|
||||||
|
setSession(next);
|
||||||
|
AsyncStorage.setItem(SESSION_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!cancelled) setBooting(false);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [persistTokens]);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.login({ username, password });
|
||||||
|
if (!res.accessToken) throw new Error('Login failed');
|
||||||
|
await persist({
|
||||||
|
token: res.accessToken,
|
||||||
|
refreshToken: res.refreshToken ?? '',
|
||||||
|
username: res.user?.username ?? username,
|
||||||
|
email: res.user?.email ?? '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (username: string, password: string, email: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.register({ username, password, email });
|
||||||
|
if (!res.accessToken) throw new Error('Registration failed');
|
||||||
|
await persist({
|
||||||
|
token: res.accessToken,
|
||||||
|
refreshToken: res.refreshToken ?? '',
|
||||||
|
username: res.user?.username ?? username,
|
||||||
|
email: res.user?.email ?? email,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
// Optimistic: flip to the login route immediately, then revoke the refresh
|
||||||
|
// token on the server in the background. Waiting on the network here can
|
||||||
|
// leave the user stuck on the authed screens if the request hangs.
|
||||||
|
const refreshToken = session?.refreshToken;
|
||||||
|
clearSession();
|
||||||
|
if (refreshToken) api.logout(refreshToken).catch(() => {});
|
||||||
|
}, [clearSession, session]);
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
token: session?.token ?? null,
|
||||||
|
username: session?.username ?? null,
|
||||||
|
email: session?.email ?? null,
|
||||||
|
booting,
|
||||||
|
busy,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
clearSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { configureApi } from '../services/apiClient';
|
||||||
|
import {
|
||||||
|
isRelease,
|
||||||
|
loadSettings,
|
||||||
|
probe,
|
||||||
|
saveSettings,
|
||||||
|
type ConnectionSettings,
|
||||||
|
type ResolvedConnection,
|
||||||
|
} from '../services/connection';
|
||||||
|
|
||||||
|
interface ConnectionContextValue {
|
||||||
|
settings: ConnectionSettings;
|
||||||
|
updateSettings: (patch: Partial<ConnectionSettings>) => void;
|
||||||
|
resolved: ResolvedConnection | null;
|
||||||
|
probing: boolean;
|
||||||
|
reprobe: () => Promise<void>;
|
||||||
|
isRelease: boolean;
|
||||||
|
/** False when no candidate host answered the probe — drives the offline strip. */
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionContext = createContext<ConnectionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [settings, setSettings] = useState<ConnectionSettings>({
|
||||||
|
mode: 'maestro',
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
useAdbReverse: true,
|
||||||
|
});
|
||||||
|
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
|
||||||
|
const [probing, setProbing] = useState(true);
|
||||||
|
const booted = useRef(false);
|
||||||
|
|
||||||
|
const runProbe = useCallback(async (s: ConnectionSettings) => {
|
||||||
|
setProbing(true);
|
||||||
|
const r = await probe(s);
|
||||||
|
setResolved(r);
|
||||||
|
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef });
|
||||||
|
setProbing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const s = await loadSettings();
|
||||||
|
if (cancelled) return;
|
||||||
|
setSettings(s);
|
||||||
|
booted.current = true;
|
||||||
|
await runProbe(s);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [runProbe]);
|
||||||
|
|
||||||
|
const updateSettings = useCallback(
|
||||||
|
(patch: Partial<ConnectionSettings>) => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
saveSettings(next);
|
||||||
|
runProbe(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[runProbe],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reprobe = useCallback(async () => {
|
||||||
|
await runProbe(settings);
|
||||||
|
}, [settings, runProbe]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
resolved,
|
||||||
|
probing,
|
||||||
|
reprobe,
|
||||||
|
isRelease,
|
||||||
|
online: !!resolved && !!resolved.baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnection(): ConnectionContextValue {
|
||||||
|
const ctx = useContext(ConnectionContext);
|
||||||
|
if (!ctx) throw new Error('useConnection must be used within ConnectionProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { Uniwind } from 'uniwind';
|
||||||
|
|
||||||
|
const THEME_KEY = 'settings.theme';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
isDark: boolean;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(THEME_KEY);
|
||||||
|
const dark = raw === 'dark';
|
||||||
|
if (!cancelled) setIsDark(dark);
|
||||||
|
Uniwind.setTheme(dark ? 'dark' : 'light');
|
||||||
|
} catch {
|
||||||
|
Uniwind.setTheme('light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setIsDark((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
Uniwind.setTheme(next ? 'dark' : 'light');
|
||||||
|
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={{ isDark, toggleTheme }}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
|
import {
|
||||||
|
AlarmSocket,
|
||||||
|
DEFAULT_RECONNECT_POLICY,
|
||||||
|
type AlarmFrame,
|
||||||
|
type SocketState,
|
||||||
|
} from '../services/alarmSocket';
|
||||||
|
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
|
||||||
|
import { alarmStatus as fetchAlarmStatus, type AlarmStatus } from '../services/alarmApi';
|
||||||
|
|
||||||
|
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
|
||||||
|
|
||||||
|
export interface UseAlarmSocketOptions {
|
||||||
|
resolved: ResolvedConnection | null;
|
||||||
|
alertId: string | null;
|
||||||
|
subscribeToken: string | null;
|
||||||
|
/** Poll cadence while the app is foregrounded (spec: 5s). */
|
||||||
|
foregroundPollMs?: number;
|
||||||
|
/** Poll cadence while backgrounded (spec: 30s). */
|
||||||
|
backgroundPollMs?: number;
|
||||||
|
onTrigger: (frame: AlarmFrame) => void;
|
||||||
|
onDisarm: (frame: AlarmFrame) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAlarmSocketResult {
|
||||||
|
socketState: SocketState;
|
||||||
|
delivery: LiveDelivery;
|
||||||
|
attempts: number;
|
||||||
|
lastStatus: AlarmStatus | null;
|
||||||
|
/** Force an immediate status read (used by pull-to-refresh and Settings). */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the live-alarm transport for one subscribed alert.
|
||||||
|
*
|
||||||
|
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
|
||||||
|
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
|
||||||
|
* Settings/health screen can say so out loud instead of pretending.
|
||||||
|
*/
|
||||||
|
export function useAlarmSocket({
|
||||||
|
resolved,
|
||||||
|
alertId,
|
||||||
|
subscribeToken,
|
||||||
|
foregroundPollMs = 5000,
|
||||||
|
backgroundPollMs = 30000,
|
||||||
|
onTrigger,
|
||||||
|
onDisarm,
|
||||||
|
enabled = true,
|
||||||
|
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
||||||
|
const [socketState, setSocketState] = useState<SocketState>('idle');
|
||||||
|
const [attempts, setAttempts] = useState(0);
|
||||||
|
const [lastStatus, setLastStatus] = useState<AlarmStatus | null>(null);
|
||||||
|
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
|
||||||
|
|
||||||
|
const socketRef = useRef<AlarmSocket | null>(null);
|
||||||
|
const triggerRef = useRef(onTrigger);
|
||||||
|
const disarmRef = useRef(onDisarm);
|
||||||
|
triggerRef.current = onTrigger;
|
||||||
|
disarmRef.current = onDisarm;
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
|
||||||
|
[resolved, alertId, subscribeToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!alertId) return;
|
||||||
|
try {
|
||||||
|
setLastStatus(await fetchAlarmStatus(alertId));
|
||||||
|
} catch {
|
||||||
|
// A failed status read is not fatal — the socket is the primary path and
|
||||||
|
// the next poll retries.
|
||||||
|
}
|
||||||
|
}, [alertId]);
|
||||||
|
|
||||||
|
// AppState awareness: while backgrounded the JS socket is unreliable, so the
|
||||||
|
// poll cadence relaxes to the background value and the socket is left to the
|
||||||
|
// native foreground service (which holds its own connection).
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', next => setAppState(next));
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !url || !alertId || !subscribeToken) {
|
||||||
|
setSocketState('idle');
|
||||||
|
setAttempts(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const socket = new AlarmSocket({
|
||||||
|
url,
|
||||||
|
policy: DEFAULT_RECONNECT_POLICY,
|
||||||
|
onStateChange: (state, nextAttempts) => {
|
||||||
|
setSocketState(state);
|
||||||
|
setAttempts(nextAttempts);
|
||||||
|
},
|
||||||
|
onAlarm: frame => {
|
||||||
|
if (frame.action === 'trigger') triggerRef.current(frame);
|
||||||
|
else disarmRef.current(frame);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
socketRef.current = socket;
|
||||||
|
socket.connect();
|
||||||
|
return () => {
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [enabled, url, alertId, subscribeToken]);
|
||||||
|
|
||||||
|
// Polling is always on while armed: it is the fallback when the socket is not
|
||||||
|
// available (maestro mode) and the safety net when it is momentarily down.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !alertId) return;
|
||||||
|
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const status = await fetchAlarmStatus(alertId);
|
||||||
|
if (!cancelled) setLastStatus(status);
|
||||||
|
} catch {
|
||||||
|
// ignore — polling must never surface a spinner
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const handle = setInterval(tick, interval);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(handle);
|
||||||
|
};
|
||||||
|
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
|
||||||
|
|
||||||
|
return { socketState, delivery, attempts, lastStatus, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Alarm palette — thin re-export of the frozen design tokens so screen code has
|
||||||
|
* one import for colours (`palette.ts` mirrors the house client shape) and one
|
||||||
|
* for everything else (`tokens.ts`).
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
bg,
|
||||||
|
surface,
|
||||||
|
surfaceRaised,
|
||||||
|
border,
|
||||||
|
text,
|
||||||
|
textMuted,
|
||||||
|
textFaint,
|
||||||
|
focusRing,
|
||||||
|
scrim,
|
||||||
|
stateIdle,
|
||||||
|
stateArmed,
|
||||||
|
stateAlarm,
|
||||||
|
stateAlarmInk,
|
||||||
|
stateAlarmDeep,
|
||||||
|
stateArmedInk,
|
||||||
|
inkOnLight,
|
||||||
|
inkOnDisarmed,
|
||||||
|
ok,
|
||||||
|
okInk,
|
||||||
|
warn,
|
||||||
|
warnInk,
|
||||||
|
danger,
|
||||||
|
dangerDeep,
|
||||||
|
inkOnFocus,
|
||||||
|
takeoverGradient,
|
||||||
|
takeoverFlat,
|
||||||
|
onAlarm,
|
||||||
|
onAlarmDim
|
||||||
|
} from './tokens';
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Alarm design tokens — the raw literals from
|
||||||
|
* `planning/service/alarm/DESIGN.md` (frozen token contract), plus spacing,
|
||||||
|
* radius, type and motion scales.
|
||||||
|
*
|
||||||
|
* Kept as plain literals (no runtime colour math, no `color-mix()`), mirroring
|
||||||
|
* `calendar-client/src/theme/palette.ts`. Tailwind/uniwind utility classes are
|
||||||
|
* used for layout; these literals are used wherever a value must be exact
|
||||||
|
* (SVG strokes, `Animated` interpolation, `elevation`, lucide icon colours).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── §2.1 Ground, surface, text ───────────────────────────────────────────────
|
||||||
|
export const bg = '#0B0D10';
|
||||||
|
export const surface = '#171B21';
|
||||||
|
export const surfaceRaised = '#1D222A';
|
||||||
|
/** Decorative hairlines only (1.37:1) — never the sole control boundary. */
|
||||||
|
export const border = '#262B34';
|
||||||
|
export const text = '#F2F4F7';
|
||||||
|
export const textMuted = '#A8AFBA';
|
||||||
|
export const textFaint = '#7C8593';
|
||||||
|
/**
|
||||||
|
* 13–14px secondary body copy (10.37:1 on `surface`). Use this for
|
||||||
|
* sub-headers and instruction lines; `textFaint` is the floor and must never be
|
||||||
|
* used below 13px (design-lead correction, mockup pass).
|
||||||
|
*/
|
||||||
|
export const textSecondary = '#C3C9D2';
|
||||||
|
/** Focus / selected outline AND any border that is a control boundary (8.81:1). */
|
||||||
|
export const focusRing = '#7CB0FF';
|
||||||
|
export const scrim = 'rgba(11,13,16,0.72)';
|
||||||
|
|
||||||
|
// ── §2.2 Semantic ramp ───────────────────────────────────────────────────────
|
||||||
|
export const stateIdle = '#9AA1AE';
|
||||||
|
export const stateArmed = '#FFC24B';
|
||||||
|
export const stateAlarm = '#FF5A3C';
|
||||||
|
export const stateDisarmed = '#5FE0C8';
|
||||||
|
/** Alarm text/icon on `surface` at 13px (6.95:1). */
|
||||||
|
export const stateAlarmInk = '#FF8A72';
|
||||||
|
/** Filled alarm control WITH white text (5.53:1) — never text on dark. */
|
||||||
|
export const stateAlarmDeep = '#C2341B';
|
||||||
|
/** Armed text on filled armed chips. */
|
||||||
|
export const stateArmedInk = '#FFD98A';
|
||||||
|
/** Text on filled `stateArmed` — dark-on-amber, never white. */
|
||||||
|
export const inkOnLight = '#2A0A04';
|
||||||
|
/** Text on filled `stateDisarmed`. */
|
||||||
|
export const inkOnDisarmed = '#04211C';
|
||||||
|
|
||||||
|
// ── §2.3 Status ramps ────────────────────────────────────────────────────────
|
||||||
|
export const ok = '#4ADE80';
|
||||||
|
export const okInk = '#06240F';
|
||||||
|
export const warn = '#FBBF24';
|
||||||
|
export const warnInk = '#2A1A02';
|
||||||
|
/** Destructive/irreversible (delete alert, revoke device) — NOT the alarm. */
|
||||||
|
export const danger = '#F87171';
|
||||||
|
export const dangerDeep = '#B91C1C';
|
||||||
|
/** Filled primary button text on `focusRing`. */
|
||||||
|
export const inkOnFocus = '#06121F';
|
||||||
|
|
||||||
|
// ── §2.4 Takeover gradient (flat fallback is the contract) ───────────────────
|
||||||
|
export const takeoverGradient = ['#C2341B', '#7A1B0C'] as const;
|
||||||
|
export const takeoverFlat = '#A82A15';
|
||||||
|
/** Takeover panel text (white) and its 85% variant. */
|
||||||
|
export const onAlarm = '#FFFFFF';
|
||||||
|
export const onAlarmDim = 'rgba(255,255,255,0.85)';
|
||||||
|
|
||||||
|
// ── §3 Typography (px at 1× density; RN `fontSize`) ──────────────────────────
|
||||||
|
export interface TypeStyle {
|
||||||
|
fontSize: number;
|
||||||
|
lineHeight: number;
|
||||||
|
fontWeight: '400' | '500' | '600' | '700';
|
||||||
|
letterSpacing: number;
|
||||||
|
uppercase?: boolean;
|
||||||
|
fontFamily?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typeDisplay: TypeStyle = { fontSize: 34, lineHeight: 38, fontWeight: '700', letterSpacing: -0.4 };
|
||||||
|
export const typeTitle: TypeStyle = { fontSize: 24, lineHeight: 30, fontWeight: '700', letterSpacing: -0.2 };
|
||||||
|
export const typeHeading: TypeStyle = { fontSize: 18, lineHeight: 24, fontWeight: '600', letterSpacing: 0 };
|
||||||
|
export const typeBody: TypeStyle = { fontSize: 16, lineHeight: 24, fontWeight: '400', letterSpacing: 0 };
|
||||||
|
export const typeBodyStrong: TypeStyle = { fontSize: 16, lineHeight: 24, fontWeight: '600', letterSpacing: 0 };
|
||||||
|
export const typeLabel: TypeStyle = { fontSize: 14, lineHeight: 20, fontWeight: '500', letterSpacing: 0 };
|
||||||
|
export const typeCaption: TypeStyle = { fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0 };
|
||||||
|
export const typeMicro: TypeStyle = {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
uppercase: true,
|
||||||
|
};
|
||||||
|
export const typeCode: TypeStyle = {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── §4 Spacing, radius, elevation ────────────────────────────────────────────
|
||||||
|
export const space1 = 4;
|
||||||
|
export const space2 = 8;
|
||||||
|
export const space3 = 12;
|
||||||
|
export const space4 = 16;
|
||||||
|
export const space5 = 20;
|
||||||
|
export const space6 = 24;
|
||||||
|
export const space8 = 32;
|
||||||
|
export const space10 = 40;
|
||||||
|
export const space12 = 48;
|
||||||
|
|
||||||
|
export const radiusSm = 8;
|
||||||
|
export const radiusMd = 12;
|
||||||
|
export const radiusLg = 16;
|
||||||
|
export const radiusXl = 24;
|
||||||
|
export const radiusFull = 999;
|
||||||
|
|
||||||
|
/** Minimum tap target (§4). */
|
||||||
|
export const minTapTarget = 48;
|
||||||
|
|
||||||
|
export const elev0 = {};
|
||||||
|
export const elev1 = {
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.24,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
};
|
||||||
|
export const elev2 = {
|
||||||
|
elevation: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.32,
|
||||||
|
shadowRadius: 20,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── §5 Motion ────────────────────────────────────────────────────────────────
|
||||||
|
export const motionInstant = 80;
|
||||||
|
export const motionQuick = 160;
|
||||||
|
export const motionStandard = 240;
|
||||||
|
export const motionSettle = 320;
|
||||||
|
export const motionBreathe = 1800;
|
||||||
|
export const motionPulse = 700;
|
||||||
|
export const motionSiren = 900;
|
||||||
|
/** Hold-to-trigger duration (§7.3). */
|
||||||
|
export const triggerHoldMs = 1200;
|
||||||
|
|
||||||
|
/** Easing approximations for RN's `Animated` (cubic-bezier(0.2,0.8,0.2,1)). */
|
||||||
|
export const easeStandard = { duration: motionStandard, useNativeDriver: true } as const;
|
||||||
|
|
||||||
|
// ── §2.2 State meta (icon + label + colour + shape) ──────────────────────────
|
||||||
|
export type AlarmState = 'idle' | 'armed' | 'triggered' | 'disarmed';
|
||||||
|
|
||||||
|
export interface StateMeta {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
/** Filled-chip text colour, when the pill is filled. */
|
||||||
|
chipText: string;
|
||||||
|
chipFill: string | null;
|
||||||
|
/** lucide icon name used by `StatePill` / state ring. */
|
||||||
|
icon: 'circle-dashed' | 'shield-check' | 'siren' | 'bell-off';
|
||||||
|
ringWidth: number;
|
||||||
|
dashed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATE_META: Record<AlarmState, StateMeta> = {
|
||||||
|
idle: {
|
||||||
|
label: 'IDLE',
|
||||||
|
color: stateIdle,
|
||||||
|
chipText: inkOnLight,
|
||||||
|
chipFill: stateIdle,
|
||||||
|
icon: 'circle-dashed',
|
||||||
|
ringWidth: 1,
|
||||||
|
dashed: true,
|
||||||
|
},
|
||||||
|
armed: {
|
||||||
|
label: 'ARMED',
|
||||||
|
color: stateArmed,
|
||||||
|
chipText: inkOnLight,
|
||||||
|
chipFill: stateArmed,
|
||||||
|
icon: 'shield-check',
|
||||||
|
ringWidth: 2,
|
||||||
|
dashed: false,
|
||||||
|
},
|
||||||
|
triggered: {
|
||||||
|
label: 'TRIGGERED',
|
||||||
|
color: stateAlarm,
|
||||||
|
chipText: '#FFFFFF',
|
||||||
|
chipFill: stateAlarmDeep,
|
||||||
|
icon: 'siren',
|
||||||
|
ringWidth: 4,
|
||||||
|
dashed: false,
|
||||||
|
},
|
||||||
|
disarmed: {
|
||||||
|
label: 'DISARMED',
|
||||||
|
color: stateDisarmed,
|
||||||
|
chipText: inkOnDisarmed,
|
||||||
|
chipFill: stateDisarmed,
|
||||||
|
icon: 'bell-off',
|
||||||
|
ringWidth: 2,
|
||||||
|
dashed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Minimal ambient types for `qrcode` (the pure-JS encoder).
|
||||||
|
*
|
||||||
|
* The package ships no bundled declarations and `@types/qrcode` is not a
|
||||||
|
* dependency of this client, so only the surface we actually use is declared:
|
||||||
|
* `create()` returning the module matrix, plus the two `toDataURL`/`toString`
|
||||||
|
* helpers kept for completeness.
|
||||||
|
*/
|
||||||
|
declare module 'qrcode' {
|
||||||
|
export interface QRCodeModules {
|
||||||
|
size: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeSymbol {
|
||||||
|
modules: QRCodeModules;
|
||||||
|
version: number;
|
||||||
|
errorCorrectionLevel: { bit: number };
|
||||||
|
maskPattern: number;
|
||||||
|
segments: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeOptions {
|
||||||
|
version?: number;
|
||||||
|
errorCorrectionLevel?: 'low' | 'medium' | 'quartile' | 'high' | 'L' | 'M' | 'Q' | 'H';
|
||||||
|
maskPattern?: number;
|
||||||
|
toSJISFunc?: (codePoint: string) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderOptions {
|
||||||
|
margin?: number;
|
||||||
|
width?: number;
|
||||||
|
color?: { dark?: string; light?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create(text: string, options?: QRCodeOptions): QRCodeSymbol;
|
||||||
|
export function toDataURL(text: string, options?: RenderOptions): Promise<string>;
|
||||||
|
export function toString(text: string, options?: RenderOptions & { type?: string }): Promise<string>;
|
||||||
|
|
||||||
|
const QRCode: {
|
||||||
|
create: typeof create;
|
||||||
|
toDataURL: typeof toDataURL;
|
||||||
|
toString: typeof toString;
|
||||||
|
};
|
||||||
|
export default QRCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// NOTE: This file is generated by uniwind and it should not be edited manually.
|
||||||
|
/// <reference types="uniwind/types" />
|
||||||
|
|
||||||
|
declare module 'uniwind' {
|
||||||
|
export interface UniwindConfig {
|
||||||
|
themes: readonly ['light', 'dark']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@react-native/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["jest"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["**/node_modules", "**/Pods"]
|
||||||
|
}
|
||||||