diff --git a/app/package-lock.json b/app/package-lock.json index e49fb7d..7f62de2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -2462,6 +2462,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/uuid": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.3.tgz", + "integrity": "sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==", + "dev": true + }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -10473,6 +10479,11 @@ } } }, + "react-native-uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-native-uuid/-/react-native-uuid-2.0.1.tgz", + "integrity": "sha512-cptnoIbL53GTCrWlb/+jrDC6tvb7ypIyzbXNJcpR3Vab0mkeaaVd5qnB3f0whXYzS+SMoSQLcUUB0gEWqkPC0g==" + }, "react-native-vector-icons": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-8.1.0.tgz", @@ -12147,11 +12158,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", @@ -12464,6 +12470,13 @@ "requires": { "simple-plist": "^1.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "xml-name-validator": { diff --git a/app/package.json b/app/package.json index 99f71a8..5763306 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "react-native-safe-area-context": "^3.3.2", "react-native-screens": "^3.8.0", "react-native-ui-lib": "^6.0.0", + "react-native-uuid": "^2.0.1", "react-native-vector-icons": "^8.1.0", "swears": "^0.1.6", "ts-proto": "^1.87.0" diff --git a/app/src/App.tsx b/app/src/App.tsx index 077281f..e38636f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,16 +4,16 @@ import { StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import Ionicons from 'react-native-vector-icons/Ionicons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { Text, TouchableOpacity } from 'react-native-ui-lib'; import Modal from 'react-native-modalbox'; // Custom -import BluetoothManager from 'bluetooth/manager'; -import { Message } from 'api/message'; -import { Peer } from 'bluetooth'; import { AppProps, AppState } from 'index'; -import { GRAPEVINE_MESSAGE } from 'Const'; +import BluetoothManager from 'bluetooth/manager'; +import { Storage } from 'storage'; +import LocalStorgage from 'storage/local'; +import { TEST_MESSAGES, TEST_PEERS } from 'data.test'; +import { TESTING } from 'const'; // Screens import HomeScreen from 'screens/home'; @@ -25,49 +25,58 @@ const Tab = createBottomTabNavigator(); class App extends React.Component { private composeRef: React.RefObject; + + private storage: Storage; + private bluetoothManager: BluetoothManager; + private stateTicker: NodeJS.Timer | undefined; + constructor(props: AppProps) { super(props); - this.composeRef = React.createRef(); + this.state = { - messages: [], - manager: new BluetoothManager( - () => this.state.messages, - async () => { - const message = (await AsyncStorage.getItem(GRAPEVINE_MESSAGE)) || ''; - return [ - { - content: message, - }, - ]; - }, - (message: Message) => { - this.setState((state) => { - return { - ...state, - messages: [...state.messages, message], - }; - }); - }, - () => this.state.peers, - (id: string, peer: Peer) => { - this.setState((state) => { - return { - ...state, - peers: { - ...state.peers, - [id]: peer, - }, - }; - }); - } - ), - peers: {}, + messages: TESTING ? TEST_MESSAGES : [], + peers: TESTING ? TEST_PEERS : {}, }; - this.state.manager.start().then(() => { + this.composeRef = React.createRef(); + + this.storage = new LocalStorgage(); + this.bluetoothManager = new BluetoothManager(this.storage); + this.stateTicker = setInterval(() => this.hydrateState(), 5 * 1000); + this.hydrateState(); + this.bluetoothManager.start().then(() => { console.log('Bluetooth manager started'); }); } + componentWillUnmount() { + this.bluetoothManager.stop().then(() => { + console.log('Bluetooth manager stopped'); + }); + if (this.stateTicker) { + clearInterval(this.stateTicker); + } + } + + async hydrateState() { + try { + const messages = await this.storage.getMessages('received'); + const peers = await this.storage.getPeers(); + this.setState((state) => { + return { + ...state, + messages: [...state.messages, ...messages], + peers: { + ...state.peers, + ...peers, + }, + }; + }); + console.log('State hydrated'); + } catch (err) { + console.error(err); + } + } + onClose() { console.log('Modal just closed'); } @@ -80,12 +89,6 @@ class App extends React.Component { console.log('the open/close of the swipeToClose just changed', state); } - componentWillUnmount() { - this.state.manager.stop().then(() => { - console.log('Bluetooth manager stopped'); - }); - } - render() { return ( diff --git a/app/src/bluetooth/central.ts b/app/src/bluetooth/central.ts index fb59605..28c21b1 100644 --- a/app/src/bluetooth/central.ts +++ b/app/src/bluetooth/central.ts @@ -5,31 +5,18 @@ import { } from 'bluetooth/const'; import { Messages } from 'api/message'; import { fromByteArray } from 'base64-js'; -import { - GetPeers, - GetTransmittableMessages, - Peer, - SetPeer, - Task, -} from 'bluetooth'; +import { Peer, Task } from 'bluetooth'; +import { Storage } from 'storage'; export default class BluetoothCentral implements Task { poweredOn: boolean; private manager: BleManager; - private getPeers: GetPeers; - private setPeer: SetPeer; - private getTransmittableMessages: GetTransmittableMessages; + private storage: Storage; - constructor( - getPeers: GetPeers, - setPeer: SetPeer, - getTransmittableMessages: GetTransmittableMessages - ) { + constructor(storage: Storage) { this.poweredOn = false; this.manager = new BleManager(); - this.getPeers = getPeers; - this.setPeer = setPeer; - this.getTransmittableMessages = getTransmittableMessages; + this.storage = storage; } /** @@ -87,7 +74,7 @@ export default class BluetoothCentral implements Task { } // Log a new encounter for an existing or new peer - const peers = this.getPeers(); + const peers = await this.storage.getPeers(); let peer: Peer = peers[scannedDevice.id] ? peers[scannedDevice.id] : { @@ -103,10 +90,10 @@ export default class BluetoothCentral implements Task { console.log( `Discovered device '${peer.device.id}' (${peer.encounters} / ${peer.transmissions})` ); - this.setPeer(scannedDevice.id, peer); + await this.storage.setPeer(scannedDevice.id, peer); // Encode the applicable messages - const messages = await this.getTransmittableMessages(); + const messages = await this.storage.getMessages('authored'); console.log(`Transmitting messages '${messages}'`); const messagesByteArr = Messages.encode( Messages.fromJSON({ @@ -123,15 +110,14 @@ export default class BluetoothCentral implements Task { console.log( `Discovered services ${discoveredDevice.serviceUUIDs} on device ${discoveredDevice.id}` ); - const characterisc = - await discoveredDevice.writeCharacteristicWithResponseForService( - GRAPEVINE_SERVICE_UUID, - MESSAGE_CHARACTERISTIC_UUID, - fromByteArray(messagesByteArr) - ); + await discoveredDevice.writeCharacteristicWithResponseForService( + GRAPEVINE_SERVICE_UUID, + MESSAGE_CHARACTERISTIC_UUID, + fromByteArray(messagesByteArr) + ); peer.transmissions++; console.log(`Transmitted messages to device '${discoveredDevice.id}'`); - this.setPeer(scannedDevice.id, peer); + await this.storage.setPeer(scannedDevice.id, peer); } catch (err) { console.error(err); } diff --git a/app/src/bluetooth/const.ts b/app/src/bluetooth/const.ts index 0097c78..93893ee 100644 --- a/app/src/bluetooth/const.ts +++ b/app/src/bluetooth/const.ts @@ -5,4 +5,3 @@ export const MESSAGE_CHARACTERISTIC_UUID: string = '3fa13acd-e384-40fc-87a4-2c5fe5eec067'; export const ID_CHARACTERISTIC_UUID: string = '22c180b9-5c6d-4511-8e44-973b4707113a'; -export const GRAPEVINE_MESSAGE = '@grapevine_message'; diff --git a/app/src/bluetooth/index.d.ts b/app/src/bluetooth/index.d.ts index ea15219..1b99284 100644 --- a/app/src/bluetooth/index.d.ts +++ b/app/src/bluetooth/index.d.ts @@ -1,10 +1,5 @@ -import { Message } from 'api/message'; import { Device } from 'react-native-ble-plx'; -export type GetMessages = () => Message[]; -export type GetTransmittableMessages = () => Promise; -export type SetMessage = (message: Message) => void; - export interface Peer { device: Device; encounters: number; @@ -13,8 +8,6 @@ export interface Peer { mtu: number; } export type Peers = { [key: string]: Peer }; -export type GetPeers = () => Peers; -export type SetPeer = (id: string, peer: Peer) => void; export interface Task { poweredOn: boolean; diff --git a/app/src/bluetooth/manager.ts b/app/src/bluetooth/manager.ts index 1baa7ea..72a6b8a 100644 --- a/app/src/bluetooth/manager.ts +++ b/app/src/bluetooth/manager.ts @@ -1,12 +1,6 @@ import BluetoothCentral from 'bluetooth/central'; import BluetoothPeripheral from 'bluetooth/peripheral'; -import { - GetMessages, - GetPeers, - GetTransmittableMessages, - SetMessage, - SetPeer, -} from 'bluetooth'; +import { Storage } from 'storage'; export enum BluetoothMode { Scan, @@ -18,19 +12,9 @@ export default class BluetoothManager { peripheral: BluetoothPeripheral; tickler: NodeJS.Timer | undefined; - constructor( - getMessages: GetMessages, - getTransmittableMessages: GetTransmittableMessages, - setMessage: SetMessage, - getPeers: GetPeers, - setPeer: SetPeer - ) { - this.central = new BluetoothCentral( - getPeers, - setPeer, - getTransmittableMessages - ); - this.peripheral = new BluetoothPeripheral(getMessages, setMessage); + constructor(storage: Storage) { + this.central = new BluetoothCentral(storage); + this.peripheral = new BluetoothPeripheral(storage); } /** diff --git a/app/src/bluetooth/peripheral.ts b/app/src/bluetooth/peripheral.ts index 0dc9bb5..c0add12 100644 --- a/app/src/bluetooth/peripheral.ts +++ b/app/src/bluetooth/peripheral.ts @@ -5,21 +5,24 @@ import { GRAPEVINE_SERVICE_UUID, MESSAGE_CHARACTERISTIC_UUID, } from 'bluetooth/const'; -import { Message, Messages } from 'api/message'; +import { Messages } from 'api/message'; import { toByteArray, fromByteArray } from 'base64-js'; -import { GetMessages, SetMessage, Task } from 'bluetooth'; +import { Task } from 'bluetooth'; +import { Storage } from 'storage'; export default class BluetoothPeripheral implements Task { poweredOn: boolean; private manager: Manager; + private storage: Storage; private service: Service; - constructor(getMessages: GetMessages, setMessage: SetMessage) { + constructor(storage: Storage) { this.poweredOn = false; this.manager = new Manager(); + this.storage = storage; this.service = new Service({ uuid: GRAPEVINE_SERVICE_UUID, - characteristics: [this.messageCharacteristic(getMessages, setMessage)], + characteristics: [this.messageCharacteristic()], }); } @@ -75,23 +78,22 @@ export default class BluetoothPeripheral implements Task { * @param setMessage * @returns */ - private messageCharacteristic( - getMessages: GetMessages, - setMessage: SetMessage - ): Characteristic { + private messageCharacteristic(): Characteristic { return new Characteristic({ uuid: MESSAGE_CHARACTERISTIC_UUID, properties: ['read', 'write'], permissions: ['readable', 'writeable'], onReadRequest: async (offset?: number) => { + console.log(`Read offset ${offset}`); const byteArr = Messages.encode( - Messages.fromJSON({ messages: getMessages() }) + Messages.fromJSON({ messages: await this.storage.getMessages('all') }) ).finish(); return fromByteArray(byteArr); }, onWriteRequest: async (value: string, offset?: number) => { + console.log(`Write offset ${offset}`); for (let message of Messages.decode(toByteArray(value)).messages) { - setMessage(message); + await this.storage.setMessage(message); } }, }); diff --git a/app/src/const.ts b/app/src/const.ts new file mode 100644 index 0000000..57004da --- /dev/null +++ b/app/src/const.ts @@ -0,0 +1 @@ +export const TESTING = false; diff --git a/app/src/data.test.ts b/app/src/data.test.ts new file mode 100644 index 0000000..77d00ca --- /dev/null +++ b/app/src/data.test.ts @@ -0,0 +1,48 @@ +import { Message } from 'api/message'; +import { Peers } from 'bluetooth'; +import { Device } from 'react-native-ble-plx'; + +export const TEST_MESSAGES = [ + Message.fromJSON({ + content: "Yo paul, what's good?", + }), + Message.fromJSON({ + content: 'I am sending this message via Grapevine 🥸', + }), + Message.fromJSON({ + content: 'Adam is a silly boy', + }), +]; + +export const TEST_PEERS: Peers = { + id1: { + encounters: 3, + transmissions: 2, + rssi: -47, + mtu: 5, + device: { + id: 'device1', + name: 'Device 1', + } as Device, + }, + id2: { + encounters: 3, + transmissions: 2, + rssi: -47, + mtu: 5, + device: { + id: 'device2', + name: 'Device 2', + } as Device, + }, + id3: { + encounters: 3, + transmissions: 2, + rssi: -47, + mtu: 5, + device: { + id: 'device3', + name: 'Device 3', + } as Device, + }, +}; diff --git a/app/src/index.d.ts b/app/src/index.d.ts index 84fba66..ff4bcc5 100644 --- a/app/src/index.d.ts +++ b/app/src/index.d.ts @@ -1,11 +1,9 @@ import { Message } from 'api/message'; import { Peers } from 'bluetooth'; -import BluetoothManager from 'bluetooth/manager'; interface AppProps {} interface AppState { - manager: BluetoothManager; messages: Message[]; peers: Peers; } diff --git a/app/src/storage/const.ts b/app/src/storage/const.ts new file mode 100644 index 0000000..4a31265 --- /dev/null +++ b/app/src/storage/const.ts @@ -0,0 +1,3 @@ +export const MESSAGES_KEY = 'messages'; +export const PEERS_KEY = 'peers'; +export const USER_ID_KEY = 'user_id'; diff --git a/app/src/storage/index.d.ts b/app/src/storage/index.d.ts new file mode 100644 index 0000000..7ca30ac --- /dev/null +++ b/app/src/storage/index.d.ts @@ -0,0 +1,23 @@ +import { Message } from 'api/message'; +import { Peer, Peers } from 'bluetooth'; + +export type MessageFilter = 'all' | 'authored' | 'received'; + +export interface Storage { + /** + * Gets messages depending on the type + */ + getMessages: (type?: MessageFilter) => Promise; + /** + * Sets a message + */ + setMessage: (message: Message) => Promise; + /** + * Gets peers + */ + getPeers: () => Promise; + /** + * Sets a peer + */ + setPeer: (id: string, peer: Peer) => Promise; +} diff --git a/app/src/storage/local.ts b/app/src/storage/local.ts new file mode 100644 index 0000000..11b8511 --- /dev/null +++ b/app/src/storage/local.ts @@ -0,0 +1,91 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Message, Messages } from 'api/message'; +import { fromByteArray, toByteArray } from 'base64-js'; +import { Peer, Peers } from 'bluetooth'; +import { MessageFilter, Storage } from 'storage'; +import { MESSAGES_KEY, PEERS_KEY, USER_ID_KEY } from './const'; +import uuid from 'react-native-uuid'; + +export default class LocalStorgage implements Storage { + private userId: string; + + constructor() { + this.userId = ''; + this.loadUserId(); + } + + async getMessages(filter: MessageFilter = 'all'): Promise { + const encodedMessages = await AsyncStorage.getItem(MESSAGES_KEY); + if (!encodedMessages) { + return []; + } + const messages = Messages.decode(toByteArray(encodedMessages)).messages; + if (filter === 'all') { + return messages; + } + this.waitForUserId(); + return messages.filter((message) => + filter === 'authored' + ? message.userId === this.userId + : message.userId !== this.userId + ); + } + + async setMessage(message: Message): Promise { + this.waitForUserId(); + message.userId = this.userId; + message.createdAt = Date.now(); + let messages = await this.getMessages(); + messages.push(message); + const messagesByteArr = Messages.encode( + Messages.fromJSON({ + messages, + }) + ).finish(); + await AsyncStorage.setItem(MESSAGES_KEY, fromByteArray(messagesByteArr)); + } + + async getPeers(): Promise { + const encodedPeers = await AsyncStorage.getItem(PEERS_KEY); + if (!encodedPeers) { + return {}; + } + return JSON.parse(encodedPeers); + } + + async setPeer(id: string, peer: Peer): Promise { + let peers = await this.getPeers(); + peers[id] = peer; + await AsyncStorage.setItem(PEERS_KEY, JSON.stringify(peers)); + } + + /** + * Load the user id into memory. The user id should be generated once on the first time the app is opened. + * Subsequent sessions should retrieve the user id from storage. + */ + private async loadUserId() { + try { + let userId = await AsyncStorage.getItem(USER_ID_KEY); + if (!userId) { + userId = uuid.v4() as string; + await AsyncStorage.setItem(USER_ID_KEY, userId); + } + this.userId = userId; + console.log(`Loaded user ${this.userId}`); + } catch (err) { + console.error(err); + this.loadUserId(); + } + } + + /** + * A lock for userId to safeguard against an op running while loadUserId is running + */ + private waitForUserId() { + while (!this.userId) {} + } + + private async purgeAll() { + await AsyncStorage.multiRemove([USER_ID_KEY, PEERS_KEY, MESSAGES_KEY]); + } +}