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 0000000..17ea1c6 Binary files /dev/null and b/frontend/src/assets/sounds/error.mp3 differ