Merge pull request #3243 from dpalou/MOBILE-3833

Mobile 3833
main
Pau Ferrer Ocaña 2022-04-08 12:40:08 +02:00 committed by GitHub
commit 155ec962ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 237 additions and 94 deletions

View File

@ -1469,6 +1469,7 @@
"core.cannotconnecttrouble": "local_moodlemobileapp", "core.cannotconnecttrouble": "local_moodlemobileapp",
"core.cannotconnectverify": "local_moodlemobileapp", "core.cannotconnectverify": "local_moodlemobileapp",
"core.cannotdownloadfiles": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp",
"core.cannotlogoutpageblocks": "local_moodlemobileapp",
"core.cannotopeninapp": "local_moodlemobileapp", "core.cannotopeninapp": "local_moodlemobileapp",
"core.cannotopeninappdownload": "local_moodlemobileapp", "core.cannotopeninappdownload": "local_moodlemobileapp",
"core.captureaudio": "local_moodlemobileapp", "core.captureaudio": "local_moodlemobileapp",

View File

@ -41,7 +41,7 @@ import { CoreLogger } from '@singletons/logger';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreIonLoadingElement } from './ion-loading'; import { CoreIonLoadingElement } from './ion-loading';
import { CoreLang } from '@services/lang'; import { CoreLang } from '@services/lang';
import { CoreSites } from '@services/sites'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { asyncInstance, AsyncInstance } from '../utils/async-instance'; import { asyncInstance, AsyncInstance } from '../utils/async-instance';
import { CoreDatabaseTable } from './database/database-table'; import { CoreDatabaseTable } from './database/database-table';
import { CoreDatabaseCachingStrategy } from './database/database-table-proxy'; import { CoreDatabaseCachingStrategy } from './database/database-table-proxy';
@ -1389,9 +1389,88 @@ export class CoreSite {
/** /**
* Get the public config of this site. * Get the public config of this site.
* *
* @param options Options.
* @return Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax. * @return Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax.
*/ */
async getPublicConfig(): Promise<CoreSitePublicConfigResponse> { async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise<CoreSitePublicConfigResponse> {
if (!this.db) {
return this.requestPublicConfig();
}
const method = 'tool_mobile_get_public_config';
const cacheId = this.getCacheId(method, {});
const cachePreSets: CoreSiteWSPreSets = {
getFromCache: true,
saveToCache: true,
emergencyCache: true,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
};
if (this.offlineDisabled) {
// Offline is disabled, don't use cache.
cachePreSets.getFromCache = false;
cachePreSets.saveToCache = false;
cachePreSets.emergencyCache = false;
}
// Check for an ongoing identical request if we're not ignoring cache.
if (cachePreSets.getFromCache && this.ongoingRequests[cacheId]) {
const response = await this.ongoingRequests[cacheId];
return response;
}
const promise = this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, false).catch(async () => {
if (cachePreSets.forceOffline) {
// Don't call the WS, just fail.
throw new CoreError(
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
);
}
// Call the WS.
try {
const config = await this.requestPublicConfig();
if (cachePreSets.saveToCache) {
this.saveToCache(method, {}, config, cachePreSets);
}
return config;
} catch (error) {
cachePreSets.omitExpires = true;
cachePreSets.getFromCache = true;
try {
return await this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, true);
} catch {
throw error;
}
}
});
this.ongoingRequests[cacheId] = promise;
// Clear ongoing request after setting the promise (just in case it's already resolved).
try {
const response = await promise;
// We pass back a clone of the original object, this may prevent errors if in the callback the object is modified.
return response;
} finally {
// Make sure we don't clear the promise of a newer request that ignores the cache.
if (this.ongoingRequests[cacheId] === promise) {
delete this.ongoingRequests[cacheId];
}
}
}
/**
* Perform a request to the server to get the public config of this site.
*
* @return Promise resolved with public config.
*/
protected async requestPublicConfig(): Promise<CoreSitePublicConfigResponse> {
const preSets: CoreWSAjaxPreSets = { const preSets: CoreWSAjaxPreSets = {
siteUrl: this.siteUrl, siteUrl: this.siteUrl,
}; };

View File

@ -11,10 +11,12 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
<ion-list id="core-course-section-selector" role="listbox" aria-labelledby="core-course-section-selector-label"> <core-loading [hideUntil]="loaded">
<ion-list *ngIf="loaded" id="core-course-section-selector" role="listbox" aria-labelledby="core-course-section-selector-label">
<ng-container *ngFor="let section of sectionsToRender"> <ng-container *ngFor="let section of sectionsToRender">
<ion-item *ngIf="allSectionId == section.id" class="divider core-course-index-all" <ion-item *ngIf="allSectionId == section.id" class="divider core-course-index-all"
(click)="selectSectionOrModule($event, section.id)" button [class.item-current]="selectedId === section.id" detail="false"> (click)="selectSectionOrModule($event, section.id)" button [class.item-current]="selectedId === section.id"
detail="false">
<ion-label> <ion-label>
<h2> <h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id"> <core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
@ -51,12 +53,14 @@
<div id="core-course-index-section-{{section.id}}"> <div id="core-course-index-section-{{section.id}}">
<ng-container *ngIf="section.expanded"> <ng-container *ngIf="section.expanded">
<ng-container *ngFor="let module of section.modules"> <ng-container *ngFor="let module of section.modules">
<ion-item class="module" [class.item-dimmed]="!module.visible" [class.item-hightlighted]="section.highlighted" <ion-item class="module" [class.item-dimmed]="!module.visible"
[class.item-hightlighted]="section.highlighted"
(click)="selectSectionOrModule($event, section.id, module.id)" button> (click)="selectSectionOrModule($event, section.id, module.id)" button>
<ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined" <ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined"
slot="start" aria-hidden="true"></ion-icon> slot="start" aria-hidden="true"></ion-icon>
<ion-icon class="completioninfo completion_incomplete" name="far-circle" <ion-icon class="completioninfo completion_incomplete" name="far-circle"
*ngIf="module.completionStatus === 0" slot="start" [attr.aria-label]="'core.course.todo' | translate"> *ngIf="module.completionStatus === 0" slot="start"
[attr.aria-label]="'core.course.todo' | translate">
</ion-icon> </ion-icon>
<ion-icon class="completioninfo completion_complete" name="fas-circle" <ion-icon class="completioninfo completion_complete" name="fas-circle"
*ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start" *ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start"
@ -81,4 +85,5 @@
</ng-container> </ng-container>
</ng-container> </ng-container>
</ion-list> </ion-list>
</core-loading>
</ion-content> </ion-content>

View File

@ -21,6 +21,7 @@ import {
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper'; import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons'; import { ModalController } from '@singletons';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
@ -41,6 +42,7 @@ export class CoreCourseCourseIndexComponent implements OnInit {
allSectionId = CoreCourseProvider.ALL_SECTIONS_ID; allSectionId = CoreCourseProvider.ALL_SECTIONS_ID;
highlighted?: string; highlighted?: string;
sectionsToRender: CourseIndexSection[] = []; sectionsToRender: CourseIndexSection[] = [];
loaded = false;
constructor( constructor(
protected elementRef: ElementRef, protected elementRef: ElementRef,
@ -109,6 +111,13 @@ export class CoreCourseCourseIndexComponent implements OnInit {
this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course); this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course);
// Wait a bit to render the data, otherwise the modal takes a while to appear in big courses or slow devices.
await CoreUtils.wait(400);
this.loaded = true;
await CoreUtils.nextTick();
CoreDom.scrollToElement( CoreDom.scrollToElement(
this.elementRef.nativeElement, this.elementRef.nativeElement,
'.item.item-current', '.item.item-current',

View File

@ -130,7 +130,27 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
* @return Promise resolved when ready. * @return Promise resolved when ready.
*/ */
protected async initCordovaMediaPlugin(): Promise<void> { protected async initCordovaMediaPlugin(): Promise<void> {
try {
await this.createFileAndMediaInstance();
this.readyToCapture = true;
this.previewMedia = this.previewAudio?.nativeElement;
} catch (error) {
this.dismissWithError(-1, error.message || error);
}
}
/**
* Create a file entry and the cordova media instance.
*/
protected async createFileAndMediaInstance(): Promise<void> {
this.filePath = this.getFilePath(); this.filePath = this.getFilePath();
// First create the file.
this.fileEntry = await CoreFile.createFile(this.filePath);
// Now create the media instance.
let absolutePath = CoreText.concatenatePaths(CoreFile.getBasePathInstant(), this.filePath); let absolutePath = CoreText.concatenatePaths(CoreFile.getBasePathInstant(), this.filePath);
if (Platform.is('ios')) { if (Platform.is('ios')) {
@ -138,17 +158,21 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
absolutePath = absolutePath.replace(/^file:\/\//, ''); absolutePath = absolutePath.replace(/^file:\/\//, '');
} }
try {
// First create the file.
this.fileEntry = await CoreFile.createFile(this.filePath);
// Now create the media instance.
this.mediaFile = Media.create(absolutePath); this.mediaFile = Media.create(absolutePath);
this.readyToCapture = true;
this.previewMedia = this.previewAudio?.nativeElement;
} catch (error) {
this.dismissWithError(-1, error.message || error);
} }
/**
* Reset the file and the cordova media instance.
*/
protected async resetCordovaMediaCapture(): Promise<void> {
if (this.filePath) {
// Remove old file, don't block the user for this.
CoreFile.removeFile(this.filePath);
}
this.mediaFile?.release();
await this.createFileAndMediaInstance();
} }
/** /**
@ -205,7 +229,8 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
return; return;
} }
if (!this.streamVideo) { const streamVideo = this.streamVideo;
if (!streamVideo) {
throw new CoreError('Video element not found.'); throw new CoreError('Video element not found.');
} }
@ -221,7 +246,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
}, 10000); }, 10000);
// Listen for stream ready to display the stream. // Listen for stream ready to display the stream.
this.streamVideo.nativeElement.onloadedmetadata = (): void => { streamVideo.nativeElement.onloadedmetadata = (): void => {
if (hasLoaded) { if (hasLoaded) {
// Already loaded or timeout triggered, stop. // Already loaded or timeout triggered, stop.
return; return;
@ -230,19 +255,13 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
hasLoaded = true; hasLoaded = true;
clearTimeout(waitTimeout); clearTimeout(waitTimeout);
this.readyToCapture = true; this.readyToCapture = true;
this.streamVideo!.nativeElement.onloadedmetadata = null; streamVideo.nativeElement.onloadedmetadata = null;
// Force change detection. Angular doesn't detect these async operations. // Force change detection. Angular doesn't detect these async operations.
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
}; };
// Set the stream as the source of the video. // Set the stream as the source of the video.
if ('srcObject' in this.streamVideo.nativeElement) { streamVideo.nativeElement.srcObject = this.localMediaStream;
this.streamVideo.nativeElement.srcObject = this.localMediaStream;
} else {
// Fallback for old browsers.
// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject#Examples
this.streamVideo.nativeElement.src = window.URL.createObjectURL(this.localMediaStream);
}
} catch (error) { } catch (error) {
this.dismissWithError(-1, error.message || error); this.dismissWithError(-1, error.message || error);
} }
@ -375,7 +394,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
this.imgCanvas.nativeElement.getContext('2d').drawImage(this.streamVideo?.nativeElement, 0, 0, width, height); this.imgCanvas.nativeElement.getContext('2d').drawImage(this.streamVideo?.nativeElement, 0, 0, width, height);
// Convert the image to blob and show it in an image element. // Convert the image to blob and show it in an image element.
this.imgCanvas.nativeElement.toBlob((blob) => { this.imgCanvas.nativeElement.toBlob((blob: Blob) => {
loadingModal.dismiss(); loadingModal.dismiss();
this.mediaBlob = blob; this.mediaBlob = blob;
@ -410,11 +429,15 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
/** /**
* Discard the captured media. * Discard the captured media.
*/ */
discard(): void { async discard(): Promise<void> {
this.previewMedia?.pause(); this.previewMedia?.pause();
this.streamVideo?.nativeElement.play(); this.streamVideo?.nativeElement.play();
this.audioDrawer?.start(); this.audioDrawer?.start();
if (this.isCordovaAudioCapture) {
await this.resetCordovaMediaCapture();
}
this.hasCaptured = false; this.hasCaptured = false;
this.isCapturing = false; this.isCapturing = false;
this.resetChrono = true; this.resetChrono = true;

View File

@ -550,10 +550,8 @@ export class CoreFileUploaderHelperProvider {
media = medias[0]; // We used limit 1, we only want 1 media. media = medias[0]; // We used limit 1, we only want 1 media.
} catch (error) { } catch (error) {
if (isAudio && this.isNoAppError(error) && CoreApp.isMobile() && if (isAudio && this.isNoAppError(error) && CoreApp.isMobile()) {
(!Platform.is('android') || CoreApp.getPlatformMajorVersion() < 10)) {
// No app to record audio, fallback to capture it ourselves. // No app to record audio, fallback to capture it ourselves.
// In Android it will only be done in Android 9 or lower because there's a bug in the plugin.
try { try {
media = await CoreFileUploader.captureAudioInApp(); media = await CoreFileUploader.captureAudioInApp();
} catch (error) { } catch (error) {

View File

@ -16,7 +16,7 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreLoginHelper } from '@features/login/services/login-helper';
@ -132,7 +132,9 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
* Get some data (like identity providers) from the site config. * Get some data (like identity providers) from the site config.
*/ */
protected async checkSiteConfig(site: CoreSite): Promise<void> { protected async checkSiteConfig(site: CoreSite): Promise<void> {
this.siteConfig = await CoreUtils.ignoreErrors(site.getPublicConfig()); this.siteConfig = await CoreUtils.ignoreErrors(site.getPublicConfig({
readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK,
}));
if (!this.siteConfig) { if (!this.siteConfig) {
return; return;

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreCronHandler } from '@services/cron'; import { CoreCronHandler } from '@services/cron';
import { CoreSites } from '@services/sites'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
@ -39,7 +39,9 @@ export class CoreLoginCronHandlerService implements CoreCronHandler {
// Do not check twice in the same 10 minutes. // Do not check twice in the same 10 minutes.
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const config = await CoreUtils.ignoreErrors(site.getPublicConfig()); const config = await CoreUtils.ignoreErrors(site.getPublicConfig({
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
}));
CoreUtils.ignoreErrors(CoreSites.checkApplication(config)); CoreUtils.ignoreErrors(CoreSites.checkApplication(config));
} }

View File

@ -78,7 +78,7 @@ export class CoreMainMenuDeepLinkManager {
if (!params?.course) { if (!params?.course) {
CoreCourseHelper.getAndOpenCourse(Number(coursePathMatches[1]), params); CoreCourseHelper.getAndOpenCourse(Number(coursePathMatches[1]), params);
} else { } else {
CoreCourse.openCourse(params.course, params); CoreCourse.openCourse(params.course, navOptions);
} }
} else { } else {
CoreNavigator.navigateToSitePath(path, { CoreNavigator.navigateToSitePath(path, {

View File

@ -28,7 +28,7 @@ import {
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons'; import { ModalController, Translate } from '@singletons';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
/** /**
@ -172,6 +172,12 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
* @param event Click event * @param event Click event
*/ */
async logout(event: Event): Promise<void> { async logout(event: Event): Promise<void> {
if (CoreNavigator.currentRouteCanBlockLeave()) {
await CoreDomUtils.showAlert(undefined, Translate.instant('core.cannotlogoutpageblocks'));
return;
}
if (this.removeAccountOnLogout) { if (this.removeAccountOnLogout) {
// Ask confirm. // Ask confirm.
const siteName = this.siteName ? const siteName = this.siteName ?
@ -200,6 +206,12 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
* @param event Click event * @param event Click event
*/ */
async switchAccounts(event: Event): Promise<void> { async switchAccounts(event: Event): Promise<void> {
if (CoreNavigator.currentRouteCanBlockLeave()) {
await CoreDomUtils.showAlert(undefined, Translate.instant('core.cannotlogoutpageblocks'));
return;
}
const thisModal = await ModalController.getTop(); const thisModal = await ModalController.getTop();
event.preventDefault(); event.preventDefault();

View File

@ -18,6 +18,7 @@
"cannotconnecttrouble": "We're having trouble connecting to your site.", "cannotconnecttrouble": "We're having trouble connecting to your site.",
"cannotconnectverify": "<strong>Please check the address is correct.</strong>", "cannotconnectverify": "<strong>Please check the address is correct.</strong>",
"cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", "cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.",
"cannotlogoutpageblocks": "Please save or discard your changes before continuing.",
"cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", "cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?",
"cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", "cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?",
"captureaudio": "Record audio", "captureaudio": "Record audio",

View File

@ -658,6 +658,15 @@ export class CoreNavigatorService {
} }
} }
/**
* Check if the current route page can block leaving the route.
*
* @return Whether the current route page can block leaving the route.
*/
currentRouteCanBlockLeave(): boolean {
return !!this.getCurrentRoute().snapshot.routeConfig?.canDeactivate?.length;
}
} }
export const CoreNavigator = makeSingleton(CoreNavigatorService); export const CoreNavigator = makeSingleton(CoreNavigatorService);

View File

@ -897,7 +897,9 @@ export class CoreSitesProvider {
*/ */
protected async getPublicConfigAndCheckApplication(site: CoreSite): Promise<void> { protected async getPublicConfigAndCheckApplication(site: CoreSite): Promise<void> {
try { try {
const config = await site.getPublicConfig(); const config = await site.getPublicConfig({
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
});
await this.checkApplication(config); await this.checkApplication(config);
} catch { } catch {