Skip to content

Commit

Permalink
feat: add mobile rct
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseRFelix committed Jan 2, 2025
1 parent 45c2621 commit fb61b2e
Show file tree
Hide file tree
Showing 19 changed files with 507 additions and 113 deletions.
3 changes: 2 additions & 1 deletion packages/mobile/.env.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Debug
EXPO_PUBLIC_OSMOSIS_ADDRESS=
EXPO_PUBLIC_OSMOSIS_ADDRESS=
EXPO_PUBLIC_OSMOSIS_BE_BASE_URL="http://localhost:3000"
38 changes: 36 additions & 2 deletions packages/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { superjson } from "@osmosis-labs/server";
import { localLink } from "@osmosis-labs/trpc";
import { localLink, makeSkipBatchLink } from "@osmosis-labs/trpc";
import {
constructEdgeRouterKey,
constructEdgeUrlPathname,
} from "@osmosis-labs/utils";
import { ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
Expand Down Expand Up @@ -97,6 +101,7 @@ export default function RootLayout() {
(opts.direction === "down" && opts.result instanceof Error),
}),
(runtime) => {
const removeLastSlash = (url: string) => url.replace(/\/$/, "");
const servers = {
local: localLink({
router: appRouter,
Expand All @@ -115,9 +120,38 @@ export default function RootLayout() {
},
opentelemetryServiceName: "osmosis-mobile",
})(runtime),
[constructEdgeRouterKey("main")]: makeSkipBatchLink(
removeLastSlash(
process.env.EXPO_PUBLIC_OSMOSIS_BE_BASE_URL ?? ""
) +
"/" +
constructEdgeUrlPathname("main")
)(runtime),
};

return (ctx) => servers["local"](ctx);
return (ctx) => {
const { op } = ctx;
const pathParts = op.path.split(".");
const basePath = pathParts.shift() as string | "osmosisFe";

/**
* Combine the rest of the parts of the paths. This is what we're actually calling on the edge server.
* E.g. It will convert `edge.pools.getPool` to `pools.getPool`
*/
const possibleOsmosisFePath = pathParts.join(".");

if (basePath === "osmosisFe") {
return servers[constructEdgeRouterKey("main")]({
...ctx,
op: {
...op,
path: possibleOsmosisFePath,
},
});
}

return servers[basePath](ctx);
};
},
],
})
Expand Down
270 changes: 232 additions & 38 deletions packages/mobile/app/onboarding/camera-scan.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,177 @@
import { STUN_SERVER } from "@osmosis-labs/utils";
import MaskedView from "@react-native-masked-view/masked-view";
import { CameraView, FocusMode } from "expo-camera";
import { useState } from "react";
import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native";
import { BarcodeScanningResult, CameraView, FocusMode } from "expo-camera";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Dimensions,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { RTCIceCandidate, RTCPeerConnection } from "react-native-webrtc";

import { RouteHeader } from "~/components/route-header";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { Colors } from "~/constants/theme-colors";
import { api } from "~/utils/trpc";

const SCAN_ICON_RADIUS_RATIO = 0.05;
const SCAN_ICON_MASK_OFFSET_RATIO = 0.02; // used for mask to match spacing in CameraScan SVG
const CAMERA_ASPECT_RATIO = 4 / 3;
const SCAN_ICON_WIDTH_RATIO = 0.7;

// Define a type for the statuses
type WebRTCStatus =
| "Init"
| "FetchingOffer"
| "NoOffer"
| "CreatingPC"
| "ChannelOpen"
| "CreatingAnswer"
| "PostingAnswer"
| "AwaitingConnection"
| "Error";

const useWebRTC = () => {
const [status, setStatus] = useState<WebRTCStatus>("Init");
const [connected, setConnected] = useState(false);

const apiUtils = api.useUtils();
const postCandidateMutation =
api.osmosisFe.webRTC.postCandidate.useMutation();
const postAnswerMutation = api.osmosisFe.webRTC.postAnswer.useMutation();

// Keep a reference to the peer connection
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const intervalIdRef = useRef<NodeJS.Timeout | null>(null);

const handleSession = useCallback(
async (sessionToken: string) => {
try {
setStatus("FetchingOffer");
// 1) Fetch the offer
const offerRes = await apiUtils.osmosisFe.webRTC.fetchOffer.ensureData({
sessionToken,
});
if (!offerRes.offerSDP) {
setStatus("NoOffer");
return;
}

setStatus("CreatingPC");
// 2) Create peer connection
const pc = new RTCPeerConnection({ iceServers: [STUN_SERVER] });
peerConnectionRef.current = pc;

// 3) When we get local ICE candidates, post them to the server
pc.onicecandidate = async (event) => {
if (event.candidate) {
await postCandidateMutation.mutate({
sessionToken,
candidate: JSON.stringify(event.candidate),
});
}
};

// 4) If the desktop created a data channel, handle it
pc.ondatachannel = (ev) => {
const channel = ev.channel;
channel.onopen = () => {
console.log("[Mobile] Data channel open, can receive data.");
setStatus("ChannelOpen");
setConnected(true);
};
channel.onmessage = (msgEvent) => {
console.log("[Mobile] Received data:", msgEvent.data);
// If the desktop sends the private key, you'd handle it here
};
};

// 5) Set remote description (the desktop’s offer)
await pc.setRemoteDescription({
type: "offer",
sdp: offerRes.offerSDP,
});

setStatus("CreatingAnswer");
// 6) Create answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);

setStatus("PostingAnswer");
await postAnswerMutation.mutate({
sessionToken,
answerSDP: answer.sdp ?? "",
});

// 7) Start polling for desktop ICE candidates
const intervalId = setInterval(async () => {
const candRes =
await apiUtils.osmosisFe.webRTC.fetchCandidates.ensureData({
sessionToken,
});
const candidates = candRes.candidates || [];
for (const cStr of candidates) {
try {
const candidate = new RTCIceCandidate(JSON.parse(cStr));
await pc.addIceCandidate(candidate);
} catch (err) {
console.warn("[Mobile] Failed to add ICE candidate:", err);
}
}
}, 3000);
intervalIdRef.current = intervalId;
setStatus("AwaitingConnection");
} catch (err: any) {
console.error(err);
setStatus("Error");
}
},
[
apiUtils.osmosisFe.webRTC.fetchCandidates,
apiUtils.osmosisFe.webRTC.fetchOffer,
postAnswerMutation,
postCandidateMutation,
]
);

useEffect(() => {
return () => {
peerConnectionRef.current?.close();
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
};
}, []);

const handleSendTest = () => {
// If we created our own data channel, you could send from here
// Or if the desktop created the channel, we handle ondatachannel
// This is optional, depending on your flow
const pc = peerConnectionRef.current;
if (!pc) return;
const sendChannel = pc.createDataChannel("mobileChannel");
sendChannel.onopen = () => {
sendChannel.send("Hello from Mobile!");
};
};

return {
status,
handleSendTest,
handleSession,
connected,
};
};

export default function Welcome() {
const { top, bottom } = useSafeAreaInsets();
const [autoFocus, setAutoFocus] = useState<FocusMode>("off");
const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false);

const { status, handleSendTest, handleSession, connected } = useWebRTC();

const resetCameraAutoFocus = () => {
const abortController = new AbortController();
Expand All @@ -28,9 +184,19 @@ export default function Welcome() {
return () => abortController.abort();
};

const onScanCode = async ({ data }: BarcodeScanningResult) => {
if (shouldFreezeCamera) {
return;
}

setShouldFreezeCamera(true);
const parsedData = JSON.parse(data);
await handleSession(parsedData.sessionToken);
setShouldFreezeCamera(false);
};

const overlayWidth = Dimensions.get("window").height / CAMERA_ASPECT_RATIO;
const cameraWidth = Dimensions.get("window").width;
const cameraHeight = CAMERA_ASPECT_RATIO * cameraWidth;
const scannerSize =
Math.min(overlayWidth, cameraWidth) * SCAN_ICON_WIDTH_RATIO;

Expand Down Expand Up @@ -75,51 +241,71 @@ export default function Welcome() {
position: "relative",
}}
>
<View
style={{
backgroundColor: "#000",
borderRadius: 32,
width: scannerSize,
height: scannerSize,
position: "relative",
marginBottom: 48,
}}
/>
{!shouldFreezeCamera && (
<View
style={{
backgroundColor: "#000",
borderRadius: 32,
width: scannerSize,
height: scannerSize,
position: "relative",
marginBottom: 48,
}}
/>
)}
</View>
}
>
<CameraView
style={{ flex: 1 }}
onBarcodeScanned={({ data }) => {
console.log(data);
}}
onBarcodeScanned={onScanCode}
autofocus={autoFocus}
/>
<View
style={{
position: "absolute",
width: scannerSize,
height: scannerSize,
top: "47.25%",
left: "50%",
transform: [
{ translateX: -scannerSize / 2 },
{ translateY: -scannerSize / 2 },
],
}}
>
<View style={{ position: "relative", flex: 1 }}>
<View style={styles.cornerTopLeft} />
<View style={styles.cornerTopRight} />
<View style={styles.cornerBottomLeft} />
<View style={styles.cornerBottomRight} />
{!shouldFreezeCamera && (
<View
style={{
position: "absolute",
width: scannerSize,
height: scannerSize,
top: "47.25%",
left: "50%",
transform: [
{ translateX: -scannerSize / 2 },
{ translateY: -scannerSize / 2 },
],
}}
>
<View style={{ position: "relative", flex: 1 }}>
<View style={styles.cornerTopLeft} />
<View style={styles.cornerTopRight} />
<View style={styles.cornerBottomLeft} />
<View style={styles.cornerBottomRight} />
</View>
</View>
</View>
)}
<TouchableOpacity
style={StyleSheet.absoluteFill}
onPress={resetCameraAutoFocus}
/>
</MaskedView>
{shouldFreezeCamera && (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
gap: 12,
}}
>
<ActivityIndicator color={Colors.white.full} />
<Text style={{ fontSize: 20, fontWeight: 600 }}>Loading...</Text>
</View>
)}
<View
style={{
justifyContent: "center",
Expand All @@ -141,6 +327,14 @@ export default function Welcome() {
to &apos;Profile&apos; &gt; &apos;Link mobile device&apos; on{"\n"}
Osmosis web app.
</Text>
{connected && (
<Button
title="Send test message"
onPress={() => {
handleSendTest();
}}
/>
)}
</View>
</View>
);
Expand Down
Loading

0 comments on commit fb61b2e

Please sign in to comment.