Skip to content

Commit

Permalink
feat: support rtc connection between mobile and browser
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseRFelix committed Jan 3, 2025
1 parent fb61b2e commit 230f0e0
Show file tree
Hide file tree
Showing 8 changed files with 385 additions and 261 deletions.
1 change: 1 addition & 0 deletions packages/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
}
]
// "@config-plugins/react-native-webrtc"
],
"experiments": {
"typedRoutes": true
Expand Down
15 changes: 7 additions & 8 deletions packages/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ export default function RootLayout() {
api.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
// loggerLink({
// enabled: (opts) =>
// process.env.NODE_ENV === "development" ||
// (opts.direction === "down" && opts.result instanceof Error),
// }),
(runtime) => {
const removeLastSlash = (url: string) => url.replace(/\/$/, "");
const servers = {
Expand All @@ -123,9 +123,7 @@ export default function RootLayout() {
[constructEdgeRouterKey("main")]: makeSkipBatchLink(
removeLastSlash(
process.env.EXPO_PUBLIC_OSMOSIS_BE_BASE_URL ?? ""
) +
"/" +
constructEdgeUrlPathname("main")
) + constructEdgeUrlPathname("main")
)(runtime),
};

Expand All @@ -141,6 +139,7 @@ export default function RootLayout() {
const possibleOsmosisFePath = pathParts.join(".");

if (basePath === "osmosisFe") {
console.log("possibleOsmosisFePath", possibleOsmosisFePath);
return servers[constructEdgeRouterKey("main")]({
...ctx,
op: {
Expand Down
132 changes: 75 additions & 57 deletions packages/mobile/app/onboarding/camera-scan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type WebRTCStatus =
| "AwaitingConnection"
| "Error";

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

Expand All @@ -44,11 +44,19 @@ const useWebRTC = () => {

// 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) => {
// Add a ref to store the data channel
const dataChannelRef = useRef<RTCDataChannel | null>(null);

useEffect(() => {
if (!sessionToken) return;
let intervalId: NodeJS.Timeout | null = null;
let isCleanedUp = false;

const startSession = async () => {
try {
if (isCleanedUp) return;

setStatus("FetchingOffer");
// 1) Fetch the offer
const offerRes = await apiUtils.osmosisFe.webRTC.fetchOffer.ensureData({
Expand All @@ -64,114 +72,126 @@ const useWebRTC = () => {
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) => {
// 3) Set remote description (the desktop’s offer)
await pc.setRemoteDescription({
type: "offer",
sdp: offerRes.offerSDP,
});

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

// 4) If the desktop created a data channel, handle it
pc.ondatachannel = (ev) => {
// 5) If the desktop created a data channel, handle it
pc.addEventListener("datachannel", (ev) => {
const channel = ev.channel;
channel.onopen = () => {
// @ts-ignore
dataChannelRef.current = channel; // Store the channel reference

channel.addEventListener("open", () => {
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,

// Send initial message when channel opens
channel.send("Hello from mobile device!");
});

channel.addEventListener("message", (msgEvent) => {
console.log("[Mobile] Received from desktop:", msgEvent.data);
// Handle incoming messages here
});

channel.addEventListener("close", () => {
console.log("[Mobile] Data channel closed");
setConnected(false);
});
});

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

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

// 7) Start polling for desktop ICE candidates
const intervalId = setInterval(async () => {
intervalId = setInterval(async () => {
const candRes =
await apiUtils.osmosisFe.webRTC.fetchCandidates.ensureData({
sessionToken,
});
const candidates = candRes.candidates || [];
for (const cStr of candidates) {
for (const c of candidates) {
try {
const candidate = new RTCIceCandidate(JSON.parse(cStr));
const candidate = new RTCIceCandidate(c);
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");
if (!isCleanedUp) setStatus("Error");
}
},
[
apiUtils.osmosisFe.webRTC.fetchCandidates,
apiUtils.osmosisFe.webRTC.fetchOffer,
postAnswerMutation,
postCandidateMutation,
]
);
};
startSession();

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

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!");
};
if (
dataChannelRef.current &&
dataChannelRef.current.readyState === "open"
) {
dataChannelRef.current.send("Test message from mobile!");
} else {
console.log("[Mobile] Data channel not ready");
}
};

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 [sessionToken, setSessionToken] = useState("");

const shouldFreezeCamera = sessionToken !== "";

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

const resetCameraAutoFocus = () => {
const abortController = new AbortController();
Expand All @@ -189,10 +209,8 @@ export default function Welcome() {
return;
}

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

const overlayWidth = Dimensions.get("window").height / CAMERA_ASPECT_RATIO;
Expand Down
39 changes: 39 additions & 0 deletions packages/mobile/hooks/use-state-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isFunction } from "@osmosis-labs/utils";
import { Dispatch, SetStateAction, useCallback, useRef, useState } from "react";

type ReadOnlyRefObject<T> = {
readonly current: T;
};

type UseStateRef = {
<S>(initialState: S | (() => S)): [
S,
Dispatch<SetStateAction<S>>,
ReadOnlyRefObject<S>
];
<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>,
ReadOnlyRefObject<S | undefined>
];
};

/**
* useState and useRef together. This is useful to get the state value
* inside an async callback instead of that value at the time the
* callback was created from.
*/
export const useStateRef: UseStateRef = <S>(initialState?: S | (() => S)) => {
const [state, setState] = useState(initialState);
const ref = useRef(state);

const dispatch: typeof setState = useCallback((setStateAction) => {
ref.current = isFunction(setStateAction)
? setStateAction(ref.current)
: setStateAction;

setState(ref.current);
}, []);

return [state, dispatch, ref];
};
11 changes: 6 additions & 5 deletions packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"preset": "jest-expo"
},
"dependencies": {
"@config-plugins/react-native-webrtc": "^10.0.0",
"@expo/vector-icons": "^14.0.2",
"@gorhom/bottom-sheet": "^5",
"@osmosis-labs/server": "^1.0.0",
Expand All @@ -39,13 +40,13 @@
"@trpc/react-query": "^10.45.1",
"dayjs": "^1.10.7",
"debounce": "^1.2.1",
"expo": "~52.0.8",
"expo": "~52.0.23",
"expo-blur": "~14.0.1",
"expo-camera": "~16.0.10",
"expo-clipboard": "^7.0.0",
"expo-constants": "~17.0.3",
"expo-crypto": "~14.0.1",
"expo-dev-client": "~5.0.4",
"expo-dev-client": "~5.0.8",
"expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linear-gradient": "~14.0.1",
Expand All @@ -61,11 +62,11 @@
"polished": "^4.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.2",
"react-native": "0.76.5",
"react-native-gesture-handler": "~2.20.2",
"react-native-haptic-feedback": "^2.3.3",
"react-native-ios-context-menu": "^2.5.2",
"react-native-ios-utilities": "^4.5.1",
"react-native-ios-context-menu": "3.0.0-23",
"react-native-ios-utilities": "5.0.0-58",
"react-native-markdown-display": "^7.0.2",
"react-native-mmkv": "^3.2.0",
"react-native-reanimated": "~3.16.1",
Expand Down
Loading

0 comments on commit 230f0e0

Please sign in to comment.