feat(app): add Android product flavors and env-based daemon config
This commit is contained in:
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"start": "npm run start --workspace=@paseo/server",
|
"start": "npm run start --workspace=@paseo/server",
|
||||||
"android": "npm run android --workspace=@paseo/app",
|
"android": "npm run android --workspace=@paseo/app",
|
||||||
"android:release": "APP_VARIANT=production npm run android --workspace=@paseo/app -- --variant release",
|
"android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app",
|
||||||
|
"android:dev-release": "ANDROID_VARIANT=devRelease npm run android --workspace=@paseo/app",
|
||||||
"ios": "npm run ios --workspace=@paseo/app",
|
"ios": "npm run ios --workspace=@paseo/app",
|
||||||
"web": "npm run web --workspace=@paseo/app",
|
"web": "npm run web --workspace=@paseo/app",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Release builds set this env var via gradle
|
// App name and package ID are controlled by Gradle product flavors (dev/production)
|
||||||
const IS_DEV = process.env.APP_VARIANT !== "production";
|
// See plugins/with-android-product-flavors.js for flavor configuration
|
||||||
|
|
||||||
|
const withAndroidProductFlavors = require("./plugins/with-android-product-flavors");
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
expo: {
|
expo: {
|
||||||
name: IS_DEV ? "Paseo (Dev)" : "Paseo",
|
name: "Paseo",
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
@@ -24,7 +26,7 @@ export default {
|
|||||||
"This app needs access to the microphone for voice commands.",
|
"This app needs access to the microphone for voice commands.",
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: IS_DEV ? "com.moboudra.paseo.dev" : "com.moboudra.paseo",
|
bundleIdentifier: "com.moboudra.paseo",
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
@@ -34,12 +36,15 @@ export default {
|
|||||||
edgeToEdgeEnabled: true,
|
edgeToEdgeEnabled: true,
|
||||||
predictiveBackGestureEnabled: false,
|
predictiveBackGestureEnabled: false,
|
||||||
softwareKeyboardLayoutMode: "resize",
|
softwareKeyboardLayoutMode: "resize",
|
||||||
|
// Allow HTTP connections for local network hosts (required for release builds)
|
||||||
|
usesCleartextTraffic: true,
|
||||||
permissions: [
|
permissions: [
|
||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||||
],
|
],
|
||||||
package: IS_DEV ? "com.moboudra.paseo.dev" : "com.moboudra.paseo",
|
// Base package - Gradle product flavors override this per variant
|
||||||
|
package: "com.moboudra.paseo",
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "static",
|
output: "static",
|
||||||
@@ -66,9 +71,12 @@ export default {
|
|||||||
android: {
|
android: {
|
||||||
minSdkVersion: 29,
|
minSdkVersion: 29,
|
||||||
kotlinVersion: "2.1.20",
|
kotlinVersion: "2.1.20",
|
||||||
|
// Allow HTTP connections for local network hosts in release builds
|
||||||
|
usesCleartextTraffic: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
withAndroidProductFlavors,
|
||||||
],
|
],
|
||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo run:android",
|
"android": "ANDROID_VARIANT=${ANDROID_VARIANT:-devDebug} expo run:android --variant $ANDROID_VARIANT",
|
||||||
|
"android:release": "ANDROID_VARIANT=${ANDROID_VARIANT:-productionRelease} expo run:android --variant $ANDROID_VARIANT",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const { withAppBuildGradle } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expo config plugin to add Android product flavors (dev/production)
|
||||||
|
* This allows building both dev and production variants without re-running prebuild
|
||||||
|
*
|
||||||
|
* Variants created:
|
||||||
|
* - devDebug: Development build with dev tools
|
||||||
|
* - devRelease: Development build optimized
|
||||||
|
* - productionDebug: Production build with dev tools
|
||||||
|
* - productionRelease: Production build optimized (for app store)
|
||||||
|
*/
|
||||||
|
function withAndroidProductFlavors(config) {
|
||||||
|
return withAppBuildGradle(config, (config) => {
|
||||||
|
let buildGradle = config.modResults.contents;
|
||||||
|
|
||||||
|
// Add debuggableVariants to the react block
|
||||||
|
buildGradle = buildGradle.replace(
|
||||||
|
'// debuggableVariants = ["liteDebug", "prodDebug"]',
|
||||||
|
'debuggableVariants = ["devDebug", "productionDebug"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add product flavors after signingConfigs block
|
||||||
|
const signingConfigsEnd = buildGradle.indexOf(
|
||||||
|
" }\n buildTypes {",
|
||||||
|
buildGradle.indexOf("signingConfigs {")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signingConfigsEnd === -1) {
|
||||||
|
console.warn(
|
||||||
|
"Could not find signingConfigs end in build.gradle, skipping product flavors"
|
||||||
|
);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productFlavorsBlock = ` }
|
||||||
|
flavorDimensions "environment"
|
||||||
|
productFlavors {
|
||||||
|
dev {
|
||||||
|
dimension "environment"
|
||||||
|
applicationId "com.moboudra.paseo.dev"
|
||||||
|
resValue "string", "app_name", "Paseo (Dev)"
|
||||||
|
}
|
||||||
|
production {
|
||||||
|
dimension "environment"
|
||||||
|
applicationId "com.moboudra.paseo"
|
||||||
|
resValue "string", "app_name", "Paseo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {`;
|
||||||
|
|
||||||
|
buildGradle = buildGradle.replace(
|
||||||
|
" }\n buildTypes {",
|
||||||
|
productFlavorsBlock
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove applicationId from defaultConfig since flavors handle it
|
||||||
|
buildGradle = buildGradle.replace(
|
||||||
|
/applicationId ['"]com\.moboudra\.paseo['"]\n\s*/,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
config.modResults.contents = buildGradle;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withAndroidProductFlavors;
|
||||||
@@ -362,8 +362,21 @@ export default function SettingsScreen() {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
wsConnection.onopen = () => succeed();
|
wsConnection.onopen = () => succeed();
|
||||||
wsConnection.onerror = () => fail("Connection failed - check URL and network");
|
wsConnection.onerror = (event) => {
|
||||||
wsConnection.onclose = () => fail("Connection failed - check URL and network");
|
const errorMessage =
|
||||||
|
(event && typeof event === "object" && "message" in event && typeof (event as any).message === "string"
|
||||||
|
? (event as any).message
|
||||||
|
: null) || "Connection failed - check URL and network";
|
||||||
|
console.error("[Settings] WebSocket test error", { url, errorMessage, event });
|
||||||
|
fail(errorMessage);
|
||||||
|
};
|
||||||
|
wsConnection.onclose = (event) => {
|
||||||
|
const reason =
|
||||||
|
(event && typeof event.reason === "string" && event.reason.trim().length > 0 ? event.reason.trim() : null) ||
|
||||||
|
`Connection failed (code ${event?.code ?? "unknown"})`;
|
||||||
|
console.error("[Settings] WebSocket test closed", { url, code: event?.code, reason });
|
||||||
|
fail(reason);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,57 @@ function createProfile(label: string, wsUrl: string): DaemonProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EnvDaemonConfig = {
|
||||||
|
label?: string;
|
||||||
|
wsUrl: string;
|
||||||
|
restUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||||
|
const envDaemons = (() => {
|
||||||
|
// Primary: allow a JSON array string like
|
||||||
|
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","wsUrl":"ws://10.0.0.1:6767/ws"}]'
|
||||||
|
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
|
||||||
|
if (jsonList) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonList) as EnvDaemonConfig[];
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[DaemonRegistry] Failed to parse EXPO_PUBLIC_DAEMONS:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: single host via EXPO_PUBLIC_DAEMON_WS_URL (+ optional label/rest URL)
|
||||||
|
const singleWsUrl = process.env.EXPO_PUBLIC_DAEMON_WS_URL;
|
||||||
|
if (singleWsUrl) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: process.env.EXPO_PUBLIC_DAEMON_LABEL,
|
||||||
|
wsUrl: singleWsUrl,
|
||||||
|
restUrl: process.env.EXPO_PUBLIC_DAEMON_REST_URL,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return envDaemons
|
||||||
|
.filter((entry) => typeof entry?.wsUrl === "string" && entry.wsUrl.trim().length > 0)
|
||||||
|
.map((entry) => ({
|
||||||
|
id: generateDaemonId(),
|
||||||
|
label: entry.label?.trim() || deriveLabelFromUrl(entry.wsUrl),
|
||||||
|
wsUrl: entry.wsUrl.trim(),
|
||||||
|
restUrl: entry.restUrl ?? null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
metadata: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||||
@@ -164,6 +215,12 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const envDefaults = parseEnvDaemonDefaults();
|
||||||
|
if (envDefaults.length > 0) {
|
||||||
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(envDefaults));
|
||||||
|
return envDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
const fallback = [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
const fallback = [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
||||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
import { createContext, useState, useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
|
import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||||
@@ -378,20 +378,13 @@ export interface SessionContextValue {
|
|||||||
|
|
||||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
export function useSession() {
|
|
||||||
const context = useContext(SessionContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSession must be used within SessionProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionProviderProps {
|
interface SessionProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionProvider feeds session state into Zustand; external consumers should use useDaemonSession or useSessionStore instead of accessing SessionContext directly.
|
||||||
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
|
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
|
||||||
const ws = useWebSocket(serverUrl);
|
const ws = useWebSocket(serverUrl);
|
||||||
const wsIsConnected = ws.isConnected;
|
const wsIsConnected = ws.isConnected;
|
||||||
|
|||||||
Reference in New Issue
Block a user