feat: offline notice

This commit is contained in:
Thea Schöbl
2023-02-13 12:19:35 +00:00
committed by Rainer Killinger
parent 11d1ac3f7c
commit 9b4caf526f
29 changed files with 548 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -19,10 +19,11 @@ import {DefaultAuthService} from './default-auth.service';
import {Browser} from 'ionic-appauth';
import {nowInSeconds, Requestor, StorageBackend} from '@openid/appauth';
import {TranslateService} from '@ngx-translate/core';
import {LoggerConfig, LoggerModule, NGXLogger} from 'ngx-logger';
import {LoggerConfig, LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {HttpClientModule} from '@angular/common/http';
import {IonicStorage} from 'ionic-appauth/lib';
import {RouterModule} from '@angular/router';
describe('AuthService', () => {
let defaultAuthService: DefaultAuthService;
@@ -34,7 +35,11 @@ describe('AuthService', () => {
storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
TestBed.configureTestingModule({
imports: [HttpClientModule, LoggerModule],
imports: [
HttpClientModule,
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
RouterModule.forRoot([]),
],
providers: [
NGXLogger,
StAppsWebHttpClient,

View File

@@ -1,8 +1,23 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {Requestor} from '@openid/appauth';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {XhrSettings} from 'ionic-appauth/lib/cordova';
import {Observable} from 'rxjs';
import {firstValueFrom, Observable} from 'rxjs';
@Injectable({
providedIn: 'root',
@@ -40,7 +55,7 @@ export class NgHttpService implements Requestor {
break;
}
return observable.toPromise();
return firstValueFrom(observable);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -215,14 +215,12 @@ export class ScheduleProvider implements OnDestroy {
if (from || to) {
const bounds: Bounds<string> = {};
if (from) {
console.log(from);
bounds.lowerBound = {
limit: from,
mode: 'inclusive',
};
}
if (to) {
console.log(to);
bounds.upperBound = {
limit: to,
mode: 'inclusive',

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -31,6 +31,8 @@ import {StorageProvider} from '../storage/storage.provider';
import {DataModule} from './data.module';
import {DataProvider, DataScope} from './data.provider';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
import {LoggerModule, NgxLoggerLevel} from 'ngx-logger';
import {RouterModule} from '@angular/router';
describe('DataProvider', () => {
let dataProvider: DataProvider;
@@ -82,7 +84,7 @@ describe('DataProvider', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [DataModule],
imports: [DataModule, LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}), RouterModule.forRoot([])],
providers: [DataProvider, StAppsWebHttpClient],
});
storageProvider = TestBed.inject(StorageProvider);

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -27,6 +27,7 @@ import {DataDetailComponent} from './data-detail.component';
import {By} from '@angular/platform-browser';
import {Observable, of} from 'rxjs';
import {StorageProvider} from '../../storage/storage.provider';
import {LoggerModule, NgxLoggerLevel} from 'ngx-logger';
const translations: any = {data: {detail: {TITLE: 'Foo'}}};
@@ -70,6 +71,7 @@ describe('DataDetailComponent', () => {
TranslateModule.forRoot({
loader: {provide: TranslateLoader, useClass: TranslateFakeLoader},
}),
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
],
providers: [
{

View File

@@ -229,13 +229,7 @@ export class SearchPageComponent implements OnInit, OnDestroy {
})();
}
} catch (error) {
const alert: HTMLIonAlertElement = await this.alertController.create({
buttons: ['Dismiss'],
header: 'Error',
subHeader: (error as Error).message,
});
await alert.present();
this.logger.error(error);
} finally {
this.loading = false;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -15,6 +15,17 @@
import {HttpClient, HttpResponse} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {HttpClientInterface, HttpClientRequest} from '@openstapps/api/lib/http-client-interface';
import {map, retry} from 'rxjs/operators';
import {lastValueFrom, Observable} from 'rxjs';
import {InternetConnectionService} from '../../util/internet-connection.service';
type HttpRequestFunctions = InstanceType<typeof HttpClient>['request'];
type HttpRequestFunction<T extends ReturnType<HttpRequestFunctions>> = Extract<
HttpRequestFunctions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(...parameters: any[]) => T
>;
type HttpRequestParameters<T extends ReturnType<HttpRequestFunctions>> = Parameters<HttpRequestFunction<T>>;
/**
* HttpClient that is based on the Angular HttpClient (@TODO: move it to provider or independent package)
@@ -23,9 +34,11 @@ import {HttpClientInterface, HttpClientRequest} from '@openstapps/api/lib/http-c
export class StAppsWebHttpClient implements HttpClientInterface {
/**
*
* @param http TODO
*/
constructor(private readonly http: HttpClient) {}
constructor(
private readonly http: HttpClient,
private readonly connectionService: InternetConnectionService,
) {}
/**
* Make a request
@@ -33,42 +46,30 @@ export class StAppsWebHttpClient implements HttpClientInterface {
* @param requestConfig Configuration of the request
*/
async request<TYPE_OF_BODY>(requestConfig: HttpClientRequest): Promise<Response<TYPE_OF_BODY>> {
const options: {
/**
* TODO
*/
[key: string]: unknown;
/**
* TODO
*/
observe: 'response';
} = {
body: {},
observe: 'response',
responseType: 'json',
};
const request: HttpRequestParameters<Observable<HttpResponse<TYPE_OF_BODY>>> = [
requestConfig.method || 'GET',
requestConfig.url.toString(),
{
body: (requestConfig.body || {}) as TYPE_OF_BODY,
headers: requestConfig.headers,
observe: 'response',
responseType: 'json',
},
];
// TODO: cache requests by hashing the parameters.
if (typeof requestConfig.body !== 'undefined') {
options.body = requestConfig.body;
}
const response: Observable<Response<TYPE_OF_BODY>> = this.http.request(...request).pipe(
retry(this.connectionService.retryConfig),
map(
response =>
Object.assign(response, {
statusCode: response.status,
body: response.body || {},
}) as Response<TYPE_OF_BODY>,
),
);
if (typeof requestConfig.headers !== 'undefined') {
options.headers = requestConfig.headers;
}
try {
const response: HttpResponse<TYPE_OF_BODY> = await this.http
.request<TYPE_OF_BODY>(requestConfig.method || 'GET', requestConfig.url.toString(), options)
.toPromise();
// eslint-disable-next-line prefer-object-spread
return Object.assign(response, {
statusCode: response.status,
body: response.body || {},
});
} catch (error) {
throw new Error(error as string);
}
return lastValueFrom(response);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -28,6 +28,7 @@ import {Observable, of} from 'rxjs';
import {StorageProvider} from '../../storage/storage.provider';
import {IonicModule} from '@ionic/angular';
import {IonIconModule} from '../../../util/ion-icon/ion-icon.module';
import {LoggerModule, NgxLoggerLevel} from 'ngx-logger';
const translations: any = {data: {detail: {TITLE: 'Foo'}}};
@@ -72,6 +73,7 @@ describe('HebisDetailComponent', () => {
TranslateModule.forRoot({
loader: {provide: TranslateLoader, useClass: TranslateFakeLoader},
}),
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
],
providers: [
{

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2021 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -20,9 +20,10 @@ import {HttpClientModule} from '@angular/common/http';
import {StorageProvider} from '../storage/storage.provider';
import {MapModule} from './map.module';
import {StorageModule} from '../storage/storage.module';
import {LoggerConfig, LoggerModule, NGXLogger} from 'ngx-logger';
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import {ConfigProvider} from '../config/config.provider';
import {sampleDefaultPolygon} from '../../_helpers/data/sample-configuration';
import {RouterModule} from '@angular/router';
describe('MapProvider', () => {
let provider: MapProvider;
@@ -31,7 +32,13 @@ describe('MapProvider', () => {
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['getValue']);
TestBed.configureTestingModule({
imports: [MapModule, HttpClientModule, StorageModule, LoggerModule],
imports: [
MapModule,
HttpClientModule,
StorageModule,
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
RouterModule.forRoot([]),
],
providers: [
{
provide: ConfigProvider,
@@ -40,7 +47,6 @@ describe('MapProvider', () => {
StAppsWebHttpClient,
StorageProvider,
NGXLogger,
LoggerConfig,
],
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -40,7 +40,7 @@ export class ContextMenuService {
/**
* Container for the filter query (SCSearchFilter)
*/
filterQuery = new Subject<SCSearchFilter>();
filterQuery = new Subject<SCSearchFilter | undefined>();
/**
* Observable filterContext streams
@@ -65,7 +65,7 @@ export class ContextMenuService {
/**
* Container for the sort query
*/
sortQuery = new Subject<SCSearchSort[]>();
sortQuery = new Subject<SCSearchSort[] | undefined>();
/**
* Observable SortContext streams

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2019 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -13,6 +13,7 @@
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<stapps-offline-notice></stapps-offline-notice>
<ion-split-pane contentId="main" when="lg">
<ion-menu menuId="main" contentId="main" type="overlay" side="start" swipe-gesture="false">
<ion-header>

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -21,9 +21,10 @@ import {IonicModule} from '@ionic/angular';
import {IonIconModule} from '../../../util/ion-icon/ion-icon.module';
import {TranslateModule} from '@ngx-translate/core';
import {RouterModule} from '@angular/router';
import {OfflineNoticeComponent} from './offline-notice.component';
@NgModule({
declarations: [RootLinkDirective, NavigationComponent, TabsComponent],
declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule],
exports: [TabsComponent, RootLinkDirective, NavigationComponent],
})

View File

@@ -1,5 +1,5 @@
/*!
* Copyright (C) 2022 StApps
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -21,8 +21,14 @@ stapps-navigation-tabs {
}
}
stapps-offline-notice.has-error ~ ion-split-pane,
stapps-offline-notice.is-offline ~ ion-split-pane {
margin-top: calc(var(--font-size-md) + 2 * var(--spacing-sm));
}
:host {
ion-split-pane {
transition: margin-top 150ms ease;
--side-max-width: 256px;
margin-bottom: calc(var(--ion-tabbar-height) + env(safe-area-inset-bottom));

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, ElementRef, HostBinding, OnDestroy, ViewChild} from '@angular/core';
import {InternetConnectionService} from '../../../util/internet-connection.service';
import {Subscription} from 'rxjs';
import {Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger';
@Component({
selector: 'stapps-offline-notice',
templateUrl: 'offline-notice.html',
styleUrls: ['offline-notice.scss'],
})
export class OfflineNoticeComponent implements OnDestroy {
@HostBinding('class.is-offline') isOffline = false;
@HostBinding('class.has-error') hasError = false;
@ViewChild('spinIcon', {read: ElementRef}) spinIcon: ElementRef;
readonly subscriptions: Subscription[];
constructor(
readonly offlineProvider: InternetConnectionService,
readonly router: Router,
readonly logger: NGXLogger,
) {
this.subscriptions = [
this.offlineProvider.offline$.subscribe(isOffline => {
this.isOffline = isOffline;
}),
this.offlineProvider.error$.subscribe(hasError => {
this.hasError = hasError;
}),
];
}
retry() {
this.spinIcon.nativeElement.classList.remove('spin');
this.spinIcon.nativeElement.offsetWidth;
this.spinIcon.nativeElement.classList.add('spin');
this.offlineProvider.retry();
}
ngOnDestroy() {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
}
}
}

View File

@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2023 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ion-button class="offline-button" color="warning">
<ion-icon slot="start" size="16" weight="800" name="cloud_off"></ion-icon>
<ion-label>{{ 'app.errors.OFFLINE' | translate }}</ion-label>
</ion-button>
<ion-button class="error-button" color="danger" (click)="retry()">
<ion-icon #spinIcon slot="start" size="16" weight="800" name="refresh"></ion-icon>
<ion-label>{{ 'app.errors.CONNECTION_ERROR' | translate }}</ion-label>
</ion-button>
<ion-button class="close" fill="clear" color="light" (click)="offlineProvider.dismissError()"
><ion-icon size="16" weight="800" name="close" slot="icon-only"></ion-icon
></ion-button>

View File

@@ -0,0 +1,77 @@
/*!
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
:host {
display: grid;
$height: calc(var(--font-size-md) + 2 * var(--spacing-sm));
height: $height;
width: 100%;
line-height: var(--font-size-md);
font-size: var(--font-size-md);
font-weight: bold;
transform: translateY(calc(-1 * $height));
transition: all 150ms ease;
&.is-offline,
&.has-error {
transform: translateY(0px);
}
> ion-button {
grid-row: 1;
grid-column: 1;
margin: 0;
--border-radius: 0;
opacity: 0;
--padding-top: 0;
--padding-bottom: 0;
transition: all 150ms ease;
z-index: 0;
&.close {
height: 100%;
margin: 0;
position: absolute;
right: 0;
top: 50%;
bottom: 0;
transform: translateY(-50%);
z-index: 1;
color: var(--ion-color-danger-contrast);
}
}
&.is-offline > .offline-button,
&.has-error > .close,
&.has-error > .error-button {
opacity: 1;
}
}
.spin {
animation: loading 1s ease running 3;
}
@keyframes loading {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright (C) 2020 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
/**
* MenuService provides bidirectional communication of context menu options and search queries
*/
@Injectable()
export class ScheduleService {}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {fromEvent, merge, ObservableInput, of, race, RetryConfig, share, Subject, takeUntil} from 'rxjs';
import {Injectable} from '@angular/core';
import {filter, map, startWith, take, tap} from 'rxjs/operators';
import {NGXLogger} from 'ngx-logger';
import {Router} from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class InternetConnectionService {
private readonly manualRetry$ = new Subject<void>();
private readonly abortRetry$ = new Subject<void>();
/**
* Emits whenever the browser goes online or offline.
*/
readonly offline$ = window
? merge(
fromEvent(window, 'online').pipe(map(() => false)),
fromEvent(window, 'offline').pipe(map(() => true)),
).pipe(startWith(!window.navigator.onLine), share())
: of(true);
/**
* Emits whenever http requests should be retried
*
* Also keeps track of when a retry is needed, automatically
* registering itself.
*/
readonly retryConfig: RetryConfig = {
delay: this.doRetry.bind(this),
};
private doRetry(error: unknown, retryCount: number): ObservableInput<unknown> {
return race(
this.offline$.pipe(
tap(it => console.log(it)),
filter(it => !it),
take(1),
),
this.manualRetry$,
).pipe(
tap({
subscribe: () => {
this.errors.add(error);
if (this.errors.size > 0) {
this.error$.next(true);
}
},
next: () => {
this.logger.error(`${retryCount}x`, error);
},
unsubscribe: () => {
this.errors.delete(error);
if (this.errors.size === 0) {
this.error$.next(false);
}
},
}),
takeUntil(
merge(
this.abortRetry$.pipe(tap(() => this.logger.warn('HTTP Request retry aborted manually'))),
this.router.events.pipe(tap(() => this.logger.warn('HTTP Request retry aborted by routing'))),
),
),
);
}
/**
* Emits when there are errors
*/
readonly error$ = new Subject<boolean>();
private readonly errors = new Set<unknown>();
constructor(private readonly logger: NGXLogger, private readonly router: Router) {}
/**
* Retry all failed http requests
*/
retry() {
this.manualRetry$.next();
}
/**
* Abandon all failed http requests
*/
dismissError() {
this.abortRetry$.next();
}
}

View File

@@ -27,7 +27,9 @@
},
"errors": {
"SERVICE": "Fehler bei Dienstausführung.",
"UNKNOWN": "Unbekannter Fehler."
"UNKNOWN": "Unbekannter Fehler.",
"OFFLINE": "Keine Internetverbindung",
"CONNECTION_ERROR": "Verbindungsfehler"
}
},
"assessments": {

View File

@@ -27,7 +27,9 @@
},
"errors": {
"SERVICE": "Service error.",
"UNKNOWN": "Unknown problem."
"UNKNOWN": "Unknown problem.",
"OFFLINE": "No internet connection",
"CONNECTION_ERROR": "Connection error"
}
},
"assessments": {

Binary file not shown.