From b97ae99b2b3a11db5b29d144c9b12232af6a737d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 28 Nov 2025 08:19:41 +0100 Subject: [PATCH] feat(app): add Android product flavors and env-based daemon config --- package.json | 3 +- packages/app/app.config.js | 18 +++-- packages/app/package.json | 4 +- .../plugins/with-android-product-flavors.js | 68 +++++++++++++++++++ packages/app/src/app/settings.tsx | 17 ++++- .../src/contexts/daemon-registry-context.tsx | 57 ++++++++++++++++ packages/app/src/contexts/session-context.tsx | 11 +-- 7 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 packages/app/plugins/with-android-product-flavors.js diff --git a/package.json b/package.json index 8693c2862..9bdfccad8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test": "npm run test --workspaces --if-present", "start": "npm run start --workspace=@paseo/server", "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", "web": "npm run web --workspace=@paseo/app", "postinstall": "patch-package" diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 2b783a84f..a202e9746 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,9 +1,11 @@ -// Release builds set this env var via gradle -const IS_DEV = process.env.APP_VARIANT !== "production"; +// App name and package ID are controlled by Gradle product flavors (dev/production) +// See plugins/with-android-product-flavors.js for flavor configuration + +const withAndroidProductFlavors = require("./plugins/with-android-product-flavors"); export default { expo: { - name: IS_DEV ? "Paseo (Dev)" : "Paseo", + name: "Paseo", slug: "voice-mobile", version: "1.0.0", orientation: "portrait", @@ -24,7 +26,7 @@ export default { "This app needs access to the microphone for voice commands.", ITSAppUsesNonExemptEncryption: false, }, - bundleIdentifier: IS_DEV ? "com.moboudra.paseo.dev" : "com.moboudra.paseo", + bundleIdentifier: "com.moboudra.paseo", }, android: { adaptiveIcon: { @@ -34,12 +36,15 @@ export default { edgeToEdgeEnabled: true, predictiveBackGestureEnabled: false, softwareKeyboardLayoutMode: "resize", + // Allow HTTP connections for local network hosts (required for release builds) + usesCleartextTraffic: true, permissions: [ "RECORD_AUDIO", "android.permission.RECORD_AUDIO", "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: { output: "static", @@ -66,9 +71,12 @@ export default { android: { minSdkVersion: 29, kotlinVersion: "2.1.20", + // Allow HTTP connections for local network hosts in release builds + usesCleartextTraffic: true, }, }, ], + withAndroidProductFlavors, ], experiments: { typedRoutes: true, diff --git a/packages/app/package.json b/packages/app/package.json index 9e0799129..68d5e2d71 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,8 +5,10 @@ "scripts": { "start": "expo start", "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:release": "expo run:ios --configuration Release", "web": "expo start --web", "lint": "expo lint", "typecheck": "tsc --noEmit", diff --git a/packages/app/plugins/with-android-product-flavors.js b/packages/app/plugins/with-android-product-flavors.js new file mode 100644 index 000000000..78c68188a --- /dev/null +++ b/packages/app/plugins/with-android-product-flavors.js @@ -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; diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 06bfaaf2b..3d22533a1 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -362,8 +362,21 @@ export default function SettingsScreen() { }, timeoutMs); wsConnection.onopen = () => succeed(); - wsConnection.onerror = () => fail("Connection failed - check URL and network"); - wsConnection.onclose = () => fail("Connection failed - check URL and network"); + wsConnection.onerror = (event) => { + 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); + }; }); }, []); diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index b7ef60d38..e2c3e4720 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -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 { try { const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); @@ -164,6 +215,12 @@ async function loadDaemonRegistryFromStorage(): Promise { } } + 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)]; await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback)); return fallback; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f0834ce5c..eb789ca1f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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 { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket"; import { useDaemonRequest } from "@/hooks/use-daemon-request"; @@ -378,20 +378,13 @@ export interface SessionContextValue { const SessionContext = createContext(null); -export function useSession() { - const context = useContext(SessionContext); - if (!context) { - throw new Error("useSession must be used within SessionProvider"); - } - return context; -} - interface SessionProviderProps { children: ReactNode; serverUrl: 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) { const ws = useWebSocket(serverUrl); const wsIsConnected = ws.isConnected;