Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.DS_Store
.vscode
managed_components/
launcherhub-app/node_modules/
components/arduino/
components/esp-dl/
components/esp-sr/
Expand Down
22 changes: 22 additions & 0 deletions launcherhub-app/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import LoginScreen from './src/screens/LoginScreen';
import CatalogScreen from './src/screens/CatalogScreen';
import DeviceScreen from './src/screens/DeviceScreen';
import FirmwareScreen from './src/screens/FirmwareScreen';

const Stack = createStackNavigator();

export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Login">
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Catalog" component={CatalogScreen} />
<Stack.Screen name="Firmware" component={FirmwareScreen} />
<Stack.Screen name="Device" component={DeviceScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
72 changes: 72 additions & 0 deletions launcherhub-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# LauncherHub Mobile App

This Expo project demonstrates Firebase-backed authentication, catalog navigation with filtering, file download, OTA updates in two phases and device discovery.

## Setup

```bash
npm install
npm start
```

Before running, export the Firebase keys as environment variables (values are stored in your project secrets):

```bash
export GH_API_KEY="<apiKey>"
export GH_AUTH_DOMAIN="<authDomain>"
export GH_PROJ_ID="<projectId>"
export GH_STORAGE="<storageBucket>"
export GH_MSG_SENDR="<messagingSenderId>"
export GH_APP_ID="<appId>"
```

The catalog is loaded from the Firestore collection `firmwares`. Each document should contain:

```json
{
"name": "Firmware A",
"brand": "BrandX",
"device": "Device 1000",
"description": "...",
"image": "https://...",
"versions": [ { "version": "1.0.0", "url": "https://..." } ]
}
```

## Testing

```bash
npm test
```

## Building

Compile a debug build for Android using the local Android SDK:

```bash
npm run android
```

For iOS, run:

```bash
npm run ios
```

Both commands require the respective platform toolchains to be installed (Android Studio or Xcode) and use Expo's native `run` workflow to produce a build.

When building for Android ensure that `ANDROID_SDK_ROOT` points to your SDK installation and that SDK/NDK licenses are accepted, e.g.:

```bash
export ANDROID_SDK_ROOT="$HOME/Android/Sdk"
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses
```

## Features
- Login screen using Firebase Authentication
- Catalog screen listing firmware files from Firestore with brand/device/name filters
- Cards show firmware image, brand and device with expandable description
- Dedicated firmware screen with version downloads
- Download files using `expo-file-system`
- Device discovery (placeholder mDNS) or manual IP entry
- Two phase OTA update: upload then apply
26 changes: 26 additions & 0 deletions launcherhub-app/app.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'dotenv/config';

export default ({ config }) => ({
...config,
expo: {
name: 'LauncherHub',
slug: 'launcherhub',
version: '1.0.0',
android: {
package: 'com.launcherhub.app'
},
ios: {
bundleIdentifier: 'com.launcherhub.app'
},
extra: {
firebase: {
apiKey: process.env.GH_API_KEY,
authDomain: process.env.GH_AUTH_DOMAIN,
projectId: process.env.GH_PROJ_ID,
storageBucket: process.env.GH_STORAGE,
messagingSenderId: process.env.GH_MSG_SENDR,
appId: process.env.GH_APP_ID
}
}
}
});
26 changes: 26 additions & 0 deletions launcherhub-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "launcherhub-app",
"version": "1.0.0",
"main": "App.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"test": "npx --yes expo-doctor"
},
"dependencies": {
"expo": "^50.0.0",
"react": "18.2.0",
"react-native": "0.73.6",
"expo-file-system": "~16.0.9",
"expo-network": "~5.8.0",
"expo-constants": "^17.1.7",
"@react-navigation/native": "^6.1.9",
"@react-navigation/stack": "^6.3.18",
"react-native-gesture-handler": "~2.14.0",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"firebase": "^10.7.1",
"dotenv": "^16.3.1"
}
}
62 changes: 62 additions & 0 deletions launcherhub-app/src/screens/CatalogScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Button, FlatList, TextInput, Image, ActivityIndicator } from 'react-native';
import { fetchCatalog } from '../services/api';

export default function CatalogScreen({ navigation }) {
const [brand, setBrand] = useState('');
const [device, setDevice] = useState('');
const [name, setName] = useState('');
const [expandedId, setExpandedId] = useState(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetchCatalog()
.then(setItems)
.finally(() => setLoading(false));
}, []);

const filtered = items.filter((item) =>
item.brand?.toLowerCase().includes(brand.toLowerCase()) &&
item.device?.toLowerCase().includes(device.toLowerCase()) &&
item.name?.toLowerCase().includes(name.toLowerCase())
);

const toggleExpand = (id) => {
setExpandedId(expandedId === id ? null : id);
};

if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator />
</View>
);
}

return (
<View style={{ flex: 1 }}>
<View style={{ padding: 16 }}>
<TextInput placeholder="Marca" value={brand} onChangeText={setBrand} />
<TextInput placeholder="Device" value={device} onChangeText={setDevice} />
<TextInput placeholder="Firmware" value={name} onChangeText={setName} />
</View>
<FlatList
data={filtered}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<View style={{ borderWidth: 1, margin: 8, padding: 16 }}>
{item.image && (
<Image source={{ uri: item.image }} style={{ height: 100, marginBottom: 8 }} />
)}
<Text>{item.name}</Text>
<Text>{item.brand} - {item.device}</Text>
<Text numberOfLines={expandedId === item.id ? undefined : 2}>{item.description}</Text>
<Button title={expandedId === item.id ? 'Menos' : 'Mais'} onPress={() => toggleExpand(item.id)} />
<Button title="Ver Detalhes" onPress={() => navigation.navigate('Firmware', { firmware: item })} />
</View>
)}
/>
</View>
);
}
46 changes: 46 additions & 0 deletions launcherhub-app/src/screens/DeviceScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { View, Text, Button, TextInput, FlatList } from 'react-native';
import { discoverDevices, manualDevice } from '../services/discovery';
import { performOTA } from '../services/ota';

export default function DeviceScreen({ route }) {
const { fileUri } = route.params;
const [devices, setDevices] = useState([]);
const [ip, setIp] = useState('');

const handleDiscover = async () => {
const found = await discoverDevices();
setDevices(found);
};

const handleManualAdd = () => {
if (ip) setDevices([...devices, manualDevice(ip)]);
};

const handleOTA = async (device) => {
try {
await performOTA(device.ip, fileUri);
alert('Update successful');
} catch (e) {
alert('OTA failed: ' + e.message);
}
};

return (
<View style={{ flex: 1, padding: 16 }}>
<Button title="Discover" onPress={handleDiscover} />
<TextInput placeholder="Manual IP" value={ip} onChangeText={setIp} style={{ borderWidth: 1, marginVertical: 8, padding: 8 }} />
<Button title="Add" onPress={handleManualAdd} />
<FlatList
data={devices}
keyExtractor={(item, idx) => item.ip + idx}
renderItem={({ item }) => (
<View style={{ padding: 8 }}>
<Text>{item.ip}</Text>
<Button title="Start OTA" onPress={() => handleOTA(item)} />
</View>
)}
/>
</View>
);
}
31 changes: 31 additions & 0 deletions launcherhub-app/src/screens/FirmwareScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, Image, Button, FlatList } from 'react-native';
import { downloadFile } from '../services/download';

export default function FirmwareScreen({ route, navigation }) {
const { firmware } = route.params;

const handleDownload = async (version) => {
const uri = await downloadFile(version.url);
navigation.navigate('Device', { fileUri: uri });
};

return (
<View style={{ padding: 16 }}>
<Image source={{ uri: firmware.image }} style={{ height: 200, marginBottom: 16 }} />
<Text>{firmware.name}</Text>
<Text>{firmware.brand} - {firmware.device}</Text>
<Text style={{ marginVertical: 8 }}>{firmware.description}</Text>
<FlatList
data={firmware.versions}
keyExtractor={(item) => item.version}
renderItem={({ item }) => (
<View style={{ marginVertical: 8 }}>
<Text>Versão {item.version}</Text>
<Button title="Download" onPress={() => handleDownload(item)} />
</View>
)}
/>
</View>
);
}
30 changes: 30 additions & 0 deletions launcherhub-app/src/screens/LoginScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useState } from 'react';
import { View, TextInput, Button, StyleSheet, Alert } from 'react-native';
import { login } from '../services/api';

export default function LoginScreen({ navigation }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

const handleLogin = async () => {
try {
await login(username, password);
navigation.replace('Catalog');
} catch (e) {
Alert.alert('Login failed', e.message);
}
};

return (
<View style={styles.container}>
<TextInput placeholder="Email" value={username} onChangeText={setUsername} style={styles.input} />
<TextInput placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry style={styles.input} />
<Button title="Login" onPress={handleLogin} />
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 16 },
input: { borderWidth: 1, marginBottom: 8, padding: 8 }
});
16 changes: 16 additions & 0 deletions launcherhub-app/src/services/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { signInWithEmailAndPassword } from 'firebase/auth';
import { collection, getDocs } from 'firebase/firestore';
import { auth, db } from './firebase';

export async function login(username, password) {
if (!username || !password) {
throw new Error('Missing credentials');
}
const { user } = await signInWithEmailAndPassword(auth, username, password);
return user;
}

export async function fetchCatalog() {
const snapshot = await getDocs(collection(db, 'firmwares'));
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}
11 changes: 11 additions & 0 deletions launcherhub-app/src/services/discovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Network from 'expo-network';

export async function discoverDevices() {
// Placeholder for mDNS discovery: returns empty list.
// Real implementation should query local network using mDNS.
return [];
}

export function manualDevice(ip) {
return { ip };
}
7 changes: 7 additions & 0 deletions launcherhub-app/src/services/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as FileSystem from 'expo-file-system';

export async function downloadFile(url) {
const fileUri = FileSystem.documentDirectory + url.split('/').pop();
const result = await FileSystem.downloadAsync(url, fileUri);
return result.uri;
}
10 changes: 10 additions & 0 deletions launcherhub-app/src/services/firebase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import Constants from 'expo-constants';

const firebaseConfig = Constants.manifest?.extra?.firebase || Constants.expoConfig?.extra?.firebase || {};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
16 changes: 16 additions & 0 deletions launcherhub-app/src/services/ota.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export async function performOTA(deviceIp, fileUri) {
// Stage 1: upload firmware
const upload = await fetch(`http://${deviceIp}/upload`, {
method: 'POST',
body: await fetch(fileUri).then(r => r.blob()),
});
if (!upload.ok) {
throw new Error('Upload failed');
}
// Stage 2: finalize update
const apply = await fetch(`http://${deviceIp}/apply`, { method: 'POST' });
if (!apply.ok) {
throw new Error('Finalize failed');
}
return true;
}
Loading