From 8b5443144965a50e5e6699a861a99acaec557738 Mon Sep 17 00:00:00 2001 From: Mondei1 Date: Sat, 14 Nov 2020 21:15:05 +0100 Subject: [PATCH] Devices can now subscribe to specific topics. - Device can now become (in)active - Error alert makes sound - Alerts now execute function on click --- .../de/nicolasklier/livebeat/MainActivity.kt | 2 +- backend/app.ts | 2 +- backend/endpoints/phone.ts | 11 ++-- backend/endpoints/user.ts | 30 ++++++++++- backend/lib/rabbit.ts | 30 +++++++++-- frontend/src/app/_alert/alert.service.ts | 7 +++ .../src/app/_alert/alert/alert.component.html | 2 +- .../src/app/_alert/alert/alert.component.scss | 2 + .../src/app/_alert/alert/alert.component.ts | 17 ++++-- frontend/src/app/admin/admin.component.html | 1 - frontend/src/app/admin/admin.component.ts | 19 ++++--- frontend/src/app/api.service.ts | 50 +++++++++++++++--- frontend/src/app/app.component.html | 16 ++++-- frontend/src/app/app.component.ts | 5 +- .../src/app/dashboard/dashboard.component.ts | 2 +- frontend/src/app/map/map.component.ts | 2 - frontend/src/app/user/user.component.html | 2 +- frontend/src/app/user/user.component.scss | 4 ++ frontend/src/app/user/user.component.ts | 9 +++- frontend/src/assets/sounds/error.mp3 | Bin 0 -> 16428 bytes 20 files changed, 171 insertions(+), 42 deletions(-) create mode 100644 frontend/src/assets/sounds/error.mp3 diff --git a/android/app/src/main/java/de/nicolasklier/livebeat/MainActivity.kt b/android/app/src/main/java/de/nicolasklier/livebeat/MainActivity.kt index 3c22968..17c26db 100644 --- a/android/app/src/main/java/de/nicolasklier/livebeat/MainActivity.kt +++ b/android/app/src/main/java/de/nicolasklier/livebeat/MainActivity.kt @@ -58,7 +58,7 @@ class MainActivity : AppCompatActivity() { val response = client.newCall(req).execute() if (response.code != 200) { - Snackbar.make(findViewById(R.id.fab), "Device isn't registered yet.", Snackbar.LENGTH_SHORT) + Snackbar.make(findViewById(R.id.fab), "Device isn't registered yet. Registering ...", Snackbar.LENGTH_SHORT) .setBackgroundTint(Color.YELLOW) .setTextColor(Color.BLACK) .show() diff --git a/backend/app.ts b/backend/app.ts index 0991ddf..7f357d5 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -92,7 +92,7 @@ async function run() { /** * Database connection */ - mongoose.set('debug', true); + //mongoose.set('debug', true); const connection = await mongoose.connect(MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true }).catch((err) => { logger.crit("Database connection could not be made: ", err); exit(1); diff --git a/backend/endpoints/phone.ts b/backend/endpoints/phone.ts index c0c33d8..329c015 100644 --- a/backend/endpoints/phone.ts +++ b/backend/endpoints/phone.ts @@ -1,9 +1,11 @@ import { Response } from "express"; -import { logger } from "../app"; +import { logger, rabbitmq } from "../app"; import { LivebeatRequest } from "../lib/request"; import { Beat } from "../models/beat/beat.model."; import { Phone } from "../models/phone/phone.model"; + + export async function GetPhone(req: LivebeatRequest, res: Response) { const phoneId: String = req.params['id']; @@ -16,7 +18,7 @@ export async function GetPhone(req: LivebeatRequest, res: Response) { // Check database for phone const phone = await Phone.findOne({ androidId: phoneId, user: req.user?._id }); - if (phone === undefined) { + if (phone === null) { res.status(404).send(); return; } @@ -53,7 +55,7 @@ export async function PostPhone(req: LivebeatRequest, res: Response) { } // Create phone - await Phone.create({ + const newPhone = await Phone.create({ androidId, displayName, modelName, @@ -63,7 +65,8 @@ export async function PostPhone(req: LivebeatRequest, res: Response) { active: false }); - logger.info(`New device (${displayName}) registered for ${req.user?.name}.`) + logger.info(`New device (${displayName}) registered for ${req.user?.name}.`); + rabbitmq.publish(req.user?.id, newPhone.toJSON(), 'phone_register') res.status(200).send(); } \ No newline at end of file diff --git a/backend/endpoints/user.ts b/backend/endpoints/user.ts index bf831d1..f91156a 100644 --- a/backend/endpoints/user.ts +++ b/backend/endpoints/user.ts @@ -232,7 +232,7 @@ export async function Resource(req: Request, res: Response) { return; } - // TODO: This has to change if we want to allow users to see the realtime movement of others. + // TODO: This has to change if we want to allow users to see the realtime movement of others. if (resource.toString().startsWith('tracker-') && resource != 'tracker-' + username) { res.status(200).send('deny'); return; @@ -242,6 +242,34 @@ export async function Resource(req: Request, res: Response) { } export async function Topic(req: Request, res: Response) { + res.status(200); + + const username = req.query.username; + const routingKey = req.query.routing_key; + + if (routingKey === undefined || username === undefined) { + res.send('deny'); + return; + } + + // Check if it's us + if (username.toString() == 'backend') { + res.status(200).send('allow'); + return; + } + + // Check if user exists + const user = await User.findOne({ name: username.toString() }); + if (user === null) { + res.send('deny'); + return; + } + + if (routingKey !== user.id) { + res.send('deny'); + return; + } + res.status(200).send('allow'); } diff --git a/backend/lib/rabbit.ts b/backend/lib/rabbit.ts index 0807b0b..aae137c 100644 --- a/backend/lib/rabbit.ts +++ b/backend/lib/rabbit.ts @@ -14,6 +14,8 @@ export class RabbitMQ { connection: amqp.Connection | null = null; channel: amqp.Channel | null = null; + timeouts: Map = new Map(); + async init() { this.connection = await amqp.connect(RABBITMQ_URI); this.channel = await this.connection.createChannel(); @@ -26,7 +28,7 @@ export class RabbitMQ { // Get phone const phone = await Phone.findOne({ androidId: msg.token }); if (phone == undefined) { - logger.info(`Received beat from unknown device with id ${msg.token}`); + logger.warning(`Received beat from unknown device with id ${msg.token}`); return; } @@ -41,13 +43,33 @@ export class RabbitMQ { battery: msg.battery, createdAt: msg.timestamp }); + + // Broadcast if device became active + if (this.timeouts.has(phone.id)) { + clearTimeout(this.timeouts.get(phone.id)); + } else { + logger.debug('Set phone active'); + phone.active = true; + await phone.save(); + this.publish(phone.user.toString(), phone.toJSON(), 'phone_alive'); + } + + const timeoutTimer = setTimeout(async () => { + this.publish(phone.user.toString(), phone.toJSON(), 'phone_dead'); + this.timeouts.delete(phone.id); + phone.active = false; + await phone.save(); + }, 30_000); + this.timeouts.set(phone.id, timeoutTimer); - this.channel!.publish('amq.topic', '.', Buffer.from(JSON.stringify(newBeat.toJSON()))); + this.publish(phone.user.toString(), newBeat.toJSON(), 'beat'); }, { noAck: true }); } - async publish(queueName = 'tracker', data: any) { + async publish(userId: string, data: any, type: 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic' = 'beat') { if (this.connection == undefined) await this.init() - this.channel?.sendToQueue(queueName, Buffer.from(data)); + + data = { type, ...data }; + this.channel?.publish('amq.topic', userId, Buffer.from(JSON.stringify(data))); } } \ No newline at end of file diff --git a/frontend/src/app/_alert/alert.service.ts b/frontend/src/app/_alert/alert.service.ts index f33641c..b97656e 100644 --- a/frontend/src/app/_alert/alert.service.ts +++ b/frontend/src/app/_alert/alert.service.ts @@ -6,6 +6,7 @@ export class Alert { type: AlertType; message: string; title?: string; + onClick?: () => void; duration = 5; isClosing = false; @@ -26,6 +27,8 @@ export class AlertService { private subject = new Subject(); private defaultId = 'default-alert'; + private errorSoundPath = 'assets/sounds/error.mp3'; + // enable subscribing to alerts observable onAlert(id = this.defaultId): Observable { return this.subject.asObservable(); @@ -37,6 +40,10 @@ export class AlertService { } error(message: string, title = 'Error!', options?: any): void { + const audio = new Audio(this.errorSoundPath); + audio.load(); + audio.play(); + this.alert(new Alert({ ...options, type: AlertType.Error, message, title })); } diff --git a/frontend/src/app/_alert/alert/alert.component.html b/frontend/src/app/_alert/alert/alert.component.html index 465c6bb..5789ee3 100644 --- a/frontend/src/app/_alert/alert/alert.component.html +++ b/frontend/src/app/_alert/alert/alert.component.html @@ -1,4 +1,4 @@ -
+
{{alert.title}} {{alert.message}} diff --git a/frontend/src/app/_alert/alert/alert.component.scss b/frontend/src/app/_alert/alert/alert.component.scss index 96901a5..b8867ee 100644 --- a/frontend/src/app/_alert/alert/alert.component.scss +++ b/frontend/src/app/_alert/alert/alert.component.scss @@ -20,6 +20,7 @@ vertical-align: middle; transition: 0.25s ease; animation: appear 0.5s ease; + cursor: pointer; box-shadow: 0 4.5px 5.3px rgba(0, 0, 0, 0.121), @@ -56,6 +57,7 @@ .alert-warning { background-color: #F3CC17 !important; + color: #000 !important; } .fadeOut { diff --git a/frontend/src/app/_alert/alert/alert.component.ts b/frontend/src/app/_alert/alert/alert.component.ts index f9bed3d..b97757a 100644 --- a/frontend/src/app/_alert/alert/alert.component.ts +++ b/frontend/src/app/_alert/alert/alert.component.ts @@ -30,9 +30,11 @@ export class AlertComponent implements OnInit, OnDestroy { this.alerts.push(alert); this.alerts = this.alerts.reverse(); - setTimeout(() => { - this.removeAlert(alert); - }, alert.duration * 1000); + if (alert.duration !== 0) { + setTimeout(() => { + this.removeAlert(alert); + }, alert.duration * 1000); + } }); } @@ -68,6 +70,15 @@ export class AlertComponent implements OnInit, OnDestroy { classes.push(alertTypeClass[alert.type]); + // Save the original onClick function. + const actualFunction = alert.onClick || null; + + // Override onClick function so that notification disappears after clicked. + alert.onClick = () => { + if (actualFunction !== null) { actualFunction(); } + this.removeAlert(alert); + }; + if (alert.isClosing) { classes.push('fadeOut'); } return classes.join(' '); diff --git a/frontend/src/app/admin/admin.component.html b/frontend/src/app/admin/admin.component.html index 2a0cf3a..73c4ef2 100644 --- a/frontend/src/app/admin/admin.component.html +++ b/frontend/src/app/admin/admin.component.html @@ -1,5 +1,4 @@

Create new user

-


diff --git a/frontend/src/app/admin/admin.component.ts b/frontend/src/app/admin/admin.component.ts index d7c39bf..42ce736 100644 --- a/frontend/src/app/admin/admin.component.ts +++ b/frontend/src/app/admin/admin.component.ts @@ -1,4 +1,5 @@ import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core'; +import { resolve } from 'dns'; import { APIService, UserType } from '../api.service'; import { AlertService } from '../_alert/alert.service'; @@ -15,7 +16,7 @@ export class AdminComponent implements AfterContentInit, OnDestroy { newType: UserType; invitationCode: string; - constructor(public api: APIService, private alertt: AlertService) { } + constructor(public api: APIService, private alert: AlertService) { } ngAfterContentInit(): void { this.api.showFilter = false; @@ -27,11 +28,17 @@ export class AdminComponent implements AfterContentInit, OnDestroy { } async createUser(): Promise { - this.invitationCode = await this.api.createUser(this.newUsername, this.newPassword, this.newType); - } - - alert() { - this.alertt.info('This is a test from admin', 'Admin says'); + this.api.createUser(this.newUsername, this.newPassword, this.newType) + .catch(error => { + if (error.statusCode === 409) { + this.alert.error('This user already exists'); + } else { + this.alert.error('There was an error while creating this user'); + } + }).then((val: string) => { + this.invitationCode = val; + this.alert.success(`User ${this.newUsername} created.`, 'Created'); + }); } } diff --git a/frontend/src/app/api.service.ts b/frontend/src/app/api.service.ts index 042a159..ba25742 100644 --- a/frontend/src/app/api.service.ts +++ b/frontend/src/app/api.service.ts @@ -4,6 +4,7 @@ import { MqttService } from 'ngx-mqtt'; import { BehaviorSubject } from 'rxjs'; import * as moment from 'moment'; import { error } from 'protractor'; +import { AlertService } from './_alert/alert.service'; export interface ILogin { token: string; @@ -107,7 +108,7 @@ export class APIService { API_ENDPOINT = 'http://192.168.178.26:8040'; - constructor(private httpClient: HttpClient, private mqtt: MqttService) { } + constructor(private httpClient: HttpClient, private mqtt: MqttService, private alert: AlertService) { } private mqttInit(): void { // Connect with RabbitMQ after we received our user information @@ -120,12 +121,31 @@ export class APIService { password: this.user.brokerToken }); - this.mqtt.observe('/').subscribe(message => { - if (this.beats !== undefined) { - const obj = JSON.parse(message.payload.toString()) as IBeat; - this.beats.push(obj); - this.beatsEvent.next([obj]); // We just push one, so all the map doesn't has to rebuild the entire map. - this.beatStats.totalBeats++; + this.mqtt.observe(this.user._id).subscribe(message => { + if (message !== undefined || message !== null) { + const obj = JSON.parse(message.payload.toString()); + + switch (obj.type) { + case 'beat': + if (this.beats !== undefined) { + this.beats.push(obj); + this.beatsEvent.next([obj]); // We just push one, so the map doesn't has to rebuild everything from scratch. + this.beatStats.totalBeats++; + } + break; + case 'phone_available': + this.alert.info(`Device ${obj.name} is now online`, 'Device'); + break; + case 'phone_register': + this.alert.success(`New device "${obj.displayName}"`, 'New device'); + break; + case 'phone_alive': + this.alert.info('Device is now active', obj.displayName.toString()); + break; + case 'phone_dead': + this.alert.warn('Device is now offline', obj.displayName.toString()); + break; + } } }); } @@ -149,6 +169,7 @@ export class APIService { await this.getBeats(); await this.getBeatStats(); this.loginEvent.next(true); + this.alert.success('Login successful', 'Login', { duration: 2 }); resolve(token as ILogin); }); }); @@ -175,8 +196,11 @@ export class APIService { .subscribe((res: any) => { this.fetchingDataEvent.next(false); console.log('Response:', res); - + resolve(res.setupToken); + }, + error => { + reject(error); }); }); } @@ -331,6 +355,16 @@ export class APIService { return earthRadiusKm * c; } + /** + * Looks if certain unique objects are present in provided object `obj`. + * + * If yes, it's a beat (true) + * @param obj Object where you think it could be a beat. + */ + isBeatObject(obj: any): boolean { + return (obj._id instanceof String && obj.battery instanceof Number && obj.accuracy instanceof Number); + } + /** * Short form for `this.api.beats[this.api.beats.length - 1]` * diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 6a3aee8..f5377f9 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -2,10 +2,18 @@
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index ac61896..203dea1 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit } from '@angular/core'; +import { faUser } from '@fortawesome/free-solid-svg-icons'; import { APIService } from './api.service'; import { AlertService } from './_alert/alert.service'; @@ -11,6 +12,7 @@ export class AppComponent implements OnInit{ title = 'Livebeat'; showOverlay = false; + faUser = faUser; constructor(public api: APIService, private alert: AlertService) { this.api.fetchingDataEvent.subscribe(status => { @@ -20,8 +22,7 @@ export class AppComponent implements OnInit{ async ngOnInit(): Promise { await this.api.login('admin', '$1KDaNCDlyXAOg'); - this.alert.success('This is just a test', 'Test'); - this.alert.error('Requested user doesn\'t exist', 'Not found'); + this.alert.error('Audio test'); return; } } diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index bb27155..f22a84f 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -58,7 +58,7 @@ export class DashboardComponent implements AfterViewInit { this.api.beatsEvent.subscribe(beats => { // Only reset array if this is not an update. - if (beats.length !== 1) { + if (beats.length > 1) { this.batteryLineChartData[0].data = []; this.batteryLineChartLabels = []; } diff --git a/frontend/src/app/map/map.component.ts b/frontend/src/app/map/map.component.ts index 9aae1c5..875b65a 100644 --- a/frontend/src/app/map/map.component.ts +++ b/frontend/src/app/map/map.component.ts @@ -159,8 +159,6 @@ export class MapComponent { } buildMap(isUpdate: boolean, maxAccuracy: number = 30): void { - console.log(isUpdate); - // If this is an update don't rebuild entire map. if (!isUpdate) { if (this.api.beats.length === 0) { diff --git a/frontend/src/app/user/user.component.html b/frontend/src/app/user/user.component.html index c7b5454..9fbed77 100644 --- a/frontend/src/app/user/user.component.html +++ b/frontend/src/app/user/user.component.html @@ -7,7 +7,7 @@

Devices

  • -

    {{phone.displayName}} last beat was {{ this.lastBeats.get(phone._id) }}

    +

    {{phone.displayName}} last beat was {{ this.lastBeats.get(phone._id) }}

    {{phone.modelName}}

diff --git a/frontend/src/app/user/user.component.scss b/frontend/src/app/user/user.component.scss index e6a7b18..2f47512 100644 --- a/frontend/src/app/user/user.component.scss +++ b/frontend/src/app/user/user.component.scss @@ -25,6 +25,10 @@ background-color: $darker; } +.offline { + color: #ff6464 +} + .lastBeat { font-weight: lighter; font-size: 10pt; diff --git a/frontend/src/app/user/user.component.ts b/frontend/src/app/user/user.component.ts index 8dd65a9..72a6898 100644 --- a/frontend/src/app/user/user.component.ts +++ b/frontend/src/app/user/user.component.ts @@ -8,7 +8,7 @@ import { ActivatedRoute } from '@angular/router'; templateUrl: './user.component.html', styleUrls: ['./user.component.scss'] }) -export class UserComponent implements AfterContentInit, OnDestroy { +export class UserComponent implements AfterContentInit, OnDestroy, OnInit { lastLogin: string; lastBeats: Map = new Map(); @@ -60,7 +60,12 @@ export class UserComponent implements AfterContentInit, OnDestroy { ngAfterContentInit(): void { this.api.showFilter = false; - console.log(this.api.showFilter); + } + + ngOnInit(): void { + if (this.api.hasSession()) { + this.api.getPhones(); + } } ngOnDestroy(): void { diff --git a/frontend/src/assets/sounds/error.mp3 b/frontend/src/assets/sounds/error.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..17ea1c6c2fb419f5a1f3e7512665fb9849e97a81 GIT binary patch literal 16428 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