feat(app): add Android product flavors and env-based daemon config

This commit is contained in:
Mohamed Boudra
2025-11-28 08:19:41 +01:00
parent 08dc2ed937
commit b97ae99b2b
7 changed files with 160 additions and 18 deletions
+2 -1
View File
@@ -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"
+13 -5
View File
@@ -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,
+3 -1
View File
@@ -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",
@@ -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;
+15 -2
View File
@@ -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);
};
});
}, []);
@@ -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[]> {
try {
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)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(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 { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
@@ -378,20 +378,13 @@ export interface SessionContextValue {
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 {
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;