diff --git a/.travis.yml b/.travis.yml index a6099b881..d49df060d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -52,6 +52,8 @@ env: - APP_CONFIG_OAUTH2_CLIENTID=alfresco - APP_CONFIG_PLUGIN_AOS=true - APP_CONFIG_PLUGIN_CONTENT_SERVICE=true + - APP_CONFIG_ENABLE_MOBILE_APP_SWITCH=true + - APP_CONFIG_SESSION_TIME_FOR_OPEN_APP_DIALOG_DISPLAY_IN_HOURS="12" - APP_CONFIG_OAUTH2_IMPLICIT_FLOW=true - APP_CONFIG_OAUTH2_SILENT_LOGIN=true - APP_CONFIG_OAUTH2_REDIRECT_LOGOUT=/ diff --git a/Dockerfile b/Dockerfile index 4be19bd3b..b42fad0ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ ENV APP_CONFIG_OAUTH2_REDIRECT_LOGOUT="/" ENV APP_CONFIG_PLUGIN_AOS=true ENV APP_CONFIG_PLUGIN_FOLDER_RULES=true ENV APP_CONFIG_PLUGIN_CONTENT_SERVICE=true +ENV APP_CONFIG_ENABLE_MOBILE_APP_SWITCH=true +ENV APP_CONFIG_SESSION_TIME_FOR_OPEN_APP_DIALOG_DISPLAY_IN_HOURS="12" COPY docker/default.conf.template /etc/nginx/templates/ COPY docker/docker-entrypoint.d/* /docker-entrypoint.d/ diff --git a/app/src/app.config.json.tpl b/app/src/app.config.json.tpl index af8473c88..ded2bc8ce 100644 --- a/app/src/app.config.json.tpl +++ b/app/src/app.config.json.tpl @@ -6,6 +6,13 @@ "providers": "${APP_CONFIG_PROVIDER}", "authType": "${APP_CONFIG_AUTH_TYPE}", "loginRoute": "login", + "mobileAppSwitch": { + "enabled" : ${APP_CONFIG_ENABLE_MOBILE_APP_SWITCH}, + "iphoneUrl": "iosamw://", + "androidUrlPart1": "intent:///", + "androidUrlPart2": "#Intent;scheme=androidamw;package=com.alfresco.content.app;end", + "sessionTimeForOpenAppDialogDisplay": "${APP_CONFIG_SESSION_TIME_FOR_OPEN_APP_DIALOG_DISPLAY_IN_HOURS}" + }, "plugins": { "aosPlugin": ${APP_CONFIG_PLUGIN_AOS}, "contentService": ${APP_CONFIG_PLUGIN_CONTENT_SERVICE}, diff --git a/cspell.json b/cspell.json index 1fbc2db15..5b70572bb 100644 --- a/cspell.json +++ b/cspell.json @@ -17,10 +17,12 @@ "ngstack", "sidenav", "injectable", + "iosamw", "truthy", "cryptodoc", "mysites", "afts", + "androidamw", "classlist", "folderlink", "filelink", diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index 6fd65726e..d5cd7e90c 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -269,6 +269,9 @@ "MESSAGE": "Leaving will remove your access.", "YES_LABEL": "OK", "NO_LABEL": "Cancel" + }, + "MOBILE_APP": { + "MOBILE_APP_BUTTON_LABEL": "Open in App" } }, "DOCUMENT_LIST": { diff --git a/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.html b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.html new file mode 100644 index 000000000..d3697ea2c --- /dev/null +++ b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.scss b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.scss new file mode 100644 index 000000000..35a050dfa --- /dev/null +++ b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.scss @@ -0,0 +1,13 @@ +.container{ + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.mat-dialog-container{ + padding: 12px; + border-radius: 36px; + background-color: var(--theme-blue-button-color); + color: var(--theme-about-panel-background-color); +} diff --git a/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.spec.ts b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.spec.ts new file mode 100644 index 000000000..4f4540ed3 --- /dev/null +++ b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; +import { OpenInAppComponent } from './open-in-app.component'; + +describe('OpenInAppComponent', () => { + let fixture: ComponentFixture; + let component: OpenInAppComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OpenInAppComponent], + providers: [{ provide: MAT_DIALOG_DATA, useValue: { redirectUrl: 'mockRedirectUrl' } }] + }).compileComponents(); + + fixture = TestBed.createComponent(OpenInAppComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should redirect to app when click on `Open in App` button` ', async () => { + let currentLocation: string | string[]; + const windowStub: Window & typeof globalThis = { + location: { + set href(value: string | string[]) { + currentLocation = value; + } + } + } as Window & typeof globalThis; + component.window = windowStub; + const saveButton = fixture.debugElement.query(By.css('[data-automation-id="open-in-app-button"]')).nativeElement; + saveButton.dispatchEvent(new Event('click')); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(currentLocation).toBe('mockRedirectUrl'); + }); +}); diff --git a/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.ts b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.ts new file mode 100644 index 000000000..14e53cd5c --- /dev/null +++ b/projects/aca-shared/src/lib/components/open-in-app/open-in-app.component.ts @@ -0,0 +1,54 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + */ + +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; + +export interface OpenInAppDialogOptions { + redirectUrl: string; +} +@Component({ + selector: 'aca-open-in-app', + templateUrl: './open-in-app.component.html', + styleUrls: ['./open-in-app.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class OpenInAppComponent { + private redirectUrl: string; + public window: Window & typeof globalThis = window; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: OpenInAppDialogOptions + ) { + if (data) { + this.redirectUrl = data.redirectUrl; + } + } + + openInApp(): void { + this.window.location.href = this.redirectUrl; + } +} diff --git a/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.spec.ts b/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.spec.ts new file mode 100644 index 000000000..f16c62085 --- /dev/null +++ b/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.spec.ts @@ -0,0 +1,105 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + */ + +import { TestBed } from '@angular/core/testing'; +import { AppConfigService } from '@alfresco/adf-core'; +import { LibTestingModule, initialState } from '../testing/lib-testing-module'; +import { provideMockStore } from '@ngrx/store/testing'; +import { AcaMobileAppSwitcherService } from './aca-mobile-app-switcher.service'; +import { MatDialog } from '@angular/material/dialog'; + +describe('AcaMobileAppSwitcherService', () => { + let appConfig: AppConfigService; + let service: AcaMobileAppSwitcherService; + + const mockDialogRef = { + close: jasmine.createSpy('close'), + open: jasmine.createSpy('open') + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [LibTestingModule], + providers: [provideMockStore({ initialState }), { provide: MatDialog, useValue: mockDialogRef }] + }); + appConfig = TestBed.inject(AppConfigService); + appConfig.config.mobileAppSwitch = { + enabled: true, + iphoneUrl: 'iosamw://', + androidUrlPart1: 'intent:///', + androidUrlPart2: '#Intent;scheme=androidamw;package=com.alfresco.content.app;end', + sessionTimeForOpenAppDialogDisplay: 12 + }; + service = TestBed.inject(AcaMobileAppSwitcherService); + sessionStorage.clear(); + }); + + it('should set the redirectUrl to `iphoneUrl`', () => { + spyOnProperty(window.navigator, 'userAgent').and.returnValue('iphone'); + const url: string = window.location.href; + const iphoneUrl: string = appConfig.config.mobileAppSwitch.iphoneUrl + url; + service.showAppNotification(); + expect(service.redirectUrl).toEqual(iphoneUrl); + }); + + it('should set the redirectUrl to `androidUrl`', () => { + spyOnProperty(window.navigator, 'userAgent').and.returnValue('android'); + const url: string = window.location.href; + const androidUrl: string = appConfig.config.mobileAppSwitch.androidUrlPart1 + url + appConfig.config.mobileAppSwitch.androidUrlPart2; + service.showAppNotification(); + expect(service.redirectUrl).toEqual(androidUrl); + }); + + it('should check if `showAppNotification` function is called', () => { + const showAppNotificationSpy: jasmine.Spy<() => void> = spyOn(service, 'showAppNotification'); + service.checkForMobileApp(); + expect(showAppNotificationSpy).toHaveBeenCalled(); + }); + + it('should not display `openInApp` dialog box when timeDifference is less than the session time', () => { + service.checkForMobileApp(); + const showAppNotificationSpy: jasmine.Spy<() => void> = spyOn(service, 'showAppNotification'); + service.checkForMobileApp(); + expect(showAppNotificationSpy).not.toHaveBeenCalled(); + }); + + it('should check if `openInApp` dialog box is getting opened with `iphone` url', () => { + const openInAppSpy: jasmine.Spy<(redirectUrl: string) => void> = spyOn(service, 'openInApp'); + const url: string = window.location.href; + service.redirectUrl = appConfig.config.mobileAppSwitch.iphoneUrl + url; + service.showAppNotification(); + expect(openInAppSpy).toHaveBeenCalled(); + expect(mockDialogRef.open).toHaveBeenCalled(); + }); + + it('should check if `openInApp` dialog box is getting opened with `android` url', () => { + const openInAppSpy: jasmine.Spy<(redirectUrl: string) => void> = spyOn(service, 'openInApp'); + const url: string = window.location.href; + service.redirectUrl = appConfig.config.mobileAppSwitch.androidUrlPart1 + url + appConfig.config.mobileAppSwitch.androidUrlPart2; + service.showAppNotification(); + expect(openInAppSpy).toHaveBeenCalled(); + expect(mockDialogRef.open).toHaveBeenCalled(); + }); +}); diff --git a/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.ts b/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.ts new file mode 100644 index 000000000..24feeb8c5 --- /dev/null +++ b/projects/aca-shared/src/lib/services/aca-mobile-app-switcher.service.ts @@ -0,0 +1,100 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + */ + +import { AppConfigService } from '@alfresco/adf-core'; +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { OpenInAppComponent } from '../components/open-in-app/open-in-app.component'; + +export interface MobileAppSwitchConfigurationOptions { + enabled: string; + iphoneUrl: string; + androidUrlPart1: string; + androidUrlPart2: string; + sessionTimeForOpenAppDialogDisplay: string; +} +@Injectable({ + providedIn: 'root' +}) +export class AcaMobileAppSwitcherService { + private mobileAppSwitchConfig: MobileAppSwitchConfigurationOptions; + public redirectUrl: string; + + constructor(private config: AppConfigService, private dialog: MatDialog) { + this.mobileAppSwitchConfig = this.config.get('mobileAppSwitch'); + } + + checkForMobileApp(): void { + const currentTime: number = new Date().getTime(); + const sessionTime: string = sessionStorage.getItem('mobile_notification_expires_in'); + + if (sessionTime !== null) { + const sessionConvertedTime: number = parseFloat(sessionTime); + const timeDifference: number = (currentTime - sessionConvertedTime) / (1000 * 60 * 60); + const sessionTimeForOpenAppDialogDisplay: number = parseFloat(this.mobileAppSwitchConfig.sessionTimeForOpenAppDialogDisplay); + + if (timeDifference > sessionTimeForOpenAppDialogDisplay) { + this.showAppNotification(); + } + } else { + this.showAppNotification(); + } + } + + showAppNotification(): void { + const ua: string = navigator.userAgent.toLowerCase(); + const isAndroid: boolean = ua.indexOf('android') > -1; + const isIOS: boolean = ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1 || ua.indexOf('ipod') > -1; + const currentUrl: string = window.location.href; + const time: number = new Date().getTime(); + + sessionStorage.setItem('mobile_notification_expires_in', time.toString()); + + if (isIOS === true) { + this.redirectUrl = this.mobileAppSwitchConfig.iphoneUrl + currentUrl; + } else if (isAndroid === true) { + this.redirectUrl = this.mobileAppSwitchConfig.androidUrlPart1 + currentUrl + this.mobileAppSwitchConfig.androidUrlPart2; + } + + if (this.redirectUrl !== undefined && this.redirectUrl !== null) { + this.openInApp(this.redirectUrl); + } + } + + openInApp(redirectUrl: string): void { + this.dialog.open(OpenInAppComponent, { + data: { + redirectUrl + }, + width: '75%', + role: 'dialog', + position: { bottom: '50px' } + }); + } + + reset(): void { + sessionStorage.removeItem('mobile_notification_expires_in'); + } +} diff --git a/projects/aca-shared/src/lib/services/app.service.spec.ts b/projects/aca-shared/src/lib/services/app.service.spec.ts index fc85c5dac..9c3735399 100644 --- a/projects/aca-shared/src/lib/services/app.service.spec.ts +++ b/projects/aca-shared/src/lib/services/app.service.spec.ts @@ -51,6 +51,8 @@ import { TranslateService } from '@ngx-translate/core'; import { TranslateServiceMock } from '../testing/translation.service'; import { RouterTestingModule } from '@angular/router/testing'; import { RepositoryInfo } from '@alfresco/js-api'; +import { AcaMobileAppSwitcherService } from './aca-mobile-app-switcher.service'; +import { MatDialogModule } from '@angular/material/dialog'; describe('AppService', () => { let service: AppService; @@ -70,10 +72,11 @@ describe('AppService', () => { let groupService: GroupService; let storeInitialAppData: any; let store: MockStore; + let acaMobileAppSwitcherService: AcaMobileAppSwitcherService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientModule, RouterTestingModule.withRoutes([])], + imports: [HttpClientModule, RouterTestingModule.withRoutes([]), MatDialogModule], providers: [ CommonModule, SearchQueryBuilderService, @@ -138,6 +141,7 @@ describe('AppService', () => { storeInitialAppData = TestBed.inject(STORE_INITIAL_APP_DATA); store = TestBed.inject(MockStore); auth = TestBed.inject(AuthenticationService); + acaMobileAppSwitcherService = TestBed.inject(AcaMobileAppSwitcherService); service = new AppService( userPreferencesService, @@ -155,7 +159,8 @@ describe('AppService', () => { groupService, overlayContainer, storeInitialAppData, - searchQueryBuilderService + searchQueryBuilderService, + acaMobileAppSwitcherService ); }); @@ -182,7 +187,8 @@ describe('AppService', () => { groupService, overlayContainer, storeInitialAppData, - searchQueryBuilderService + searchQueryBuilderService, + acaMobileAppSwitcherService ); expect(instance.withCredentials).toBeTruthy(); diff --git a/projects/aca-shared/src/lib/services/app.service.ts b/projects/aca-shared/src/lib/services/app.service.ts index 674344939..5ba628e35 100644 --- a/projects/aca-shared/src/lib/services/app.service.ts +++ b/projects/aca-shared/src/lib/services/app.service.ts @@ -56,6 +56,7 @@ import { ContentApiService } from './content-api.service'; import { RouterExtensionService } from './router.extension.service'; import { Store } from '@ngrx/store'; import { DiscoveryEntry, GroupEntry, Group } from '@alfresco/js-api'; +import { AcaMobileAppSwitcherService } from './aca-mobile-app-switcher.service'; @Injectable({ providedIn: 'root' @@ -96,7 +97,8 @@ export class AppService implements OnDestroy { private groupService: GroupService, private overlayContainer: OverlayContainer, @Inject(STORE_INITIAL_APP_DATA) private initialAppState: AppState, - searchQueryBuilderService: SearchQueryBuilderService + searchQueryBuilderService: SearchQueryBuilderService, + private acaMobileAppSwitcherService: AcaMobileAppSwitcherService ) { this.ready = new BehaviorSubject(this.authenticationService.isLoggedIn() || this.withCredentials); this.ready$ = this.ready.asObservable(); @@ -177,6 +179,13 @@ export class AppService implements OnDestroy { }); this.overlayContainer.getContainerElement().setAttribute('role', 'region'); + + const isMobileSwitchEnabled: boolean = this.config.get('mobileAppSwitch.enabled', false); + if (isMobileSwitchEnabled) { + this.acaMobileAppSwitcherService.checkForMobileApp(); + } else { + this.acaMobileAppSwitcherService.reset(); + } } private loadRepositoryStatus() { diff --git a/projects/aca-shared/src/lib/shared.module.ts b/projects/aca-shared/src/lib/shared.module.ts index 323267e07..16e53b0d8 100644 --- a/projects/aca-shared/src/lib/shared.module.ts +++ b/projects/aca-shared/src/lib/shared.module.ts @@ -28,10 +28,15 @@ import { ContentApiService } from './services/content-api.service'; import { NodePermissionService } from './services/node-permission.service'; import { AppService } from './services/app.service'; import { ContextActionsModule } from './directives/contextmenu/contextmenu.module'; +import { OpenInAppComponent } from './components/open-in-app/open-in-app.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDialogModule } from '@angular/material/dialog'; @NgModule({ - imports: [ContextActionsModule], - exports: [ContextActionsModule] + imports: [ContextActionsModule, MatButtonModule, MatIconModule, MatDialogModule], + exports: [ContextActionsModule, MatButtonModule, MatIconModule, MatDialogModule], + declarations: [OpenInAppComponent] }) export class SharedModule { static forRoot(): ModuleWithProviders {