View Javadoc
1   /**
2    * This file Copyright (c) 2012-2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.ui.framework.app;
35  
36  import info.magnolia.cms.security.User;
37  import info.magnolia.cms.security.operations.AccessDefinition;
38  import info.magnolia.config.registry.DefinitionProvider;
39  import info.magnolia.config.registry.Registry;
40  import info.magnolia.context.Context;
41  import info.magnolia.event.EventBus;
42  import info.magnolia.i18nsystem.SimpleTranslator;
43  import info.magnolia.objectfactory.ComponentProvider;
44  import info.magnolia.objectfactory.Components;
45  import info.magnolia.ui.api.app.App;
46  import info.magnolia.ui.api.app.AppContext;
47  import info.magnolia.ui.api.app.AppController;
48  import info.magnolia.ui.api.app.AppDescriptor;
49  import info.magnolia.ui.api.app.AppInstanceController;
50  import info.magnolia.ui.api.app.AppLifecycleEvent;
51  import info.magnolia.ui.api.app.AppLifecycleEventType;
52  import info.magnolia.ui.api.app.ChooseDialogCallback;
53  import info.magnolia.ui.api.app.registry.AppDescriptorRegistry;
54  import info.magnolia.ui.api.context.UiContext;
55  import info.magnolia.ui.api.event.AdmincentralEventBus;
56  import info.magnolia.ui.api.ioc.AdmincentralScoped;
57  import info.magnolia.ui.api.location.DefaultLocation;
58  import info.magnolia.ui.api.location.Location;
59  import info.magnolia.ui.api.location.LocationChangeRequestedEvent;
60  import info.magnolia.ui.api.location.LocationChangedEvent;
61  import info.magnolia.ui.api.location.LocationController;
62  import info.magnolia.ui.api.message.Message;
63  import info.magnolia.ui.api.message.MessageType;
64  import info.magnolia.ui.api.shell.CloseAppEvent;
65  import info.magnolia.ui.api.view.Viewport;
66  import info.magnolia.ui.framework.app.stub.FailedAppStub;
67  import info.magnolia.ui.framework.ioc.AdmincentralFlavour;
68  import info.magnolia.ui.framework.ioc.SessionStore;
69  import info.magnolia.ui.framework.ioc.UiContextBoundComponentProvider;
70  import info.magnolia.ui.framework.ioc.UiContextReference;
71  import info.magnolia.ui.framework.message.MessagesManager;
72  
73  import java.util.HashMap;
74  import java.util.LinkedList;
75  import java.util.Map;
76  import java.util.Objects;
77  
78  import javax.inject.Inject;
79  import javax.inject.Named;
80  
81  import org.apache.commons.lang3.StringUtils;
82  import org.slf4j.Logger;
83  import org.slf4j.LoggerFactory;
84  
85  import com.google.inject.Key;
86  
87  /**
88   * Implementation of the {@link info.magnolia.ui.api.app.AppController}.
89   *
90   * The App controller that manages the lifecycle of running apps and raises callbacks to the app.
91   * It provides methods to start, stop and focus already running {@link info.magnolia.ui.api.app.App}s.
92   * Registers handlers to the following location change events triggered by the {@link LocationController}:
93   * <ul>
94   * <li>{@link LocationChangedEvent}</li>
95   * <li>{@link LocationChangeRequestedEvent}</li>
96   * </ul>
97   *
98   * @see LocationController
99   * @see info.magnolia.ui.api.app.AppContext
100  * @see info.magnolia.ui.api.app.App
101  */
102 @AdmincentralScoped
103 public class AppControllerImpl implements AppController, LocationChangedEvent.Handler, LocationChangeRequestedEvent.Handler, CloseAppEvent.Handler {
104 
105     private static final Logger log = LoggerFactory.getLogger(AppControllerImpl.class);
106 
107     private final ComponentProvider componentProvider;
108     private final AppDescriptorRegistry appDescriptorRegistry;
109     private final LocationController locationController;
110     private final EventBus eventBus;
111     private final Map<String, AppInstanceController> runningApps = new HashMap<>();
112     private final LinkedList<AppInstanceController> appHistory = new LinkedList<>();
113     private final MessagesManager messagesManager;
114     private final SimpleTranslator i18n;
115     private final User user;
116 
117     private Map<String, AppInstanceController> appInstances = new HashMap<>();
118     private Viewport viewport;
119     private AppInstanceController currentAppInstanceController;
120 
121     @Inject
122     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n, Context context) {
123         this.componentProvider = componentProvider;
124         this.appDescriptorRegistry = appDescriptorRegistry;
125         this.locationController = locationController;
126         this.eventBus = admincentralEventBus;
127         this.messagesManager = messagesManager;
128         this.i18n = i18n;
129         this.user = context.getUser();
130 
131         admincentralEventBus.addHandler(LocationChangedEvent.class, this);
132         admincentralEventBus.addHandler(LocationChangeRequestedEvent.class, this);
133         admincentralEventBus.addHandler(CloseAppEvent.class, this);
134     }
135 
136     /**
137      * @deprecated since 6.0 - use {@link AppControllerImpl(ComponentProvider, AppDescriptorRegistry, LocationController, EventBus, MessagesManager, SimpleTranslator, Context)} instead.
138      */
139     @Deprecated
140     public AppControllerImpl(ComponentProvider componentProvider, AppDescriptorRegistry appDescriptorRegistry, LocationController locationController, @Named(AdmincentralEventBus.NAME) EventBus admincentralEventBus, MessagesManager messagesManager, SimpleTranslator i18n) {
141         this(componentProvider, appDescriptorRegistry, locationController, admincentralEventBus, messagesManager, i18n, Components.getComponent(Context.class));
142     }
143 
144     @Override
145     public void setViewport(Viewport viewport) {
146         this.viewport = viewport;
147     }
148 
149     @Override
150     public void onCloseApp(CloseAppEvent event) {
151         stopCurrentApp();
152     }
153 
154     /**
155      * This method is called to create an instance of an app independent from the {@link LocationController} and the {@link AppController} handling.
156      * It will not open in the {@link info.magnolia.ui.api.view.Viewport} and will not register itself to the running apps.
157      * This is e.g. used to pass the {@link info.magnolia.ui.api.app.App} into a dialog and obtain app-specific information from outside the app.
158      *
159      * @param appName of the {@link info.magnolia.ui.api.app.App} to instantiate.
160      */
161     private App getAppWithoutStarting(String appName) {
162         AppInstanceController appInstanceController = createNewAppInstance(appName);
163         ComponentProvider appComponentProvider = createAppComponentProvider(appInstanceController);
164         App app = appComponentProvider.newInstance(appInstanceController.getAppDescriptor().getAppClass());
165 
166         appInstanceController.setApp(app);
167         return app;
168     }
169 
170     /**
171      * This method can be called to launch an {@link App} and then delegate it to the {@link LocationController}.
172      * It should have the same effect as calling the {@link LocationController} directly.
173      *
174      * @param appName of the {@link App} to start.
175      * @param location holds information about the subApp to use and the parameters.
176      */
177     public App startIfNotAlreadyRunningThenFocus(String appName, Location location) {
178         AppInstanceController appInstanceController = getAppInstance(appName);
179         appInstanceController = doStartIfNotAlreadyRunning(appInstanceController, location);
180         doFocus(appInstanceController);
181         return appInstanceController.getApp();
182     }
183 
184     @Override
185     public void stopApp(String appName) {
186         final AppInstanceController appInstanceController = runningApps.get(appName);
187         if (appInstanceController != null) {
188             doStop(appInstanceController);
189         }
190     }
191 
192     @Override
193     public void stopCurrentApp() {
194         final AppInstanceController appInstanceController = appHistory.peekFirst();
195         if (appInstanceController != null) {
196             doStop(appInstanceController);
197         }
198     }
199 
200     @Override
201     public boolean isAppStarted(String appName) {
202         return runningApps.containsKey(appName);
203     }
204 
205     @Override
206     public void focusCurrentApp() {
207         if (currentAppInstanceController != null) {
208             doFocus(currentAppInstanceController);
209         }
210     }
211 
212     @Override
213     public App getCurrentApp() {
214         return currentAppInstanceController == null ? null : currentAppInstanceController.getApp();
215     }
216 
217     /**
218      * Returns the current location of the focused app. This can differ from the actual location of the admin central, e.g. when a shell app is open.
219      *
220      * @see info.magnolia.ui.api.location.LocationController#getWhere()
221      */
222     @Override
223     public Location getCurrentAppLocation() {
224         return currentAppInstanceController == null ? null : currentAppInstanceController.getCurrentLocation();
225     }
226 
227     /**
228      * Returns the current location of a running app instance or null, if it is not running. The App does not have to be focused.
229      */
230     @Override
231     public Location getAppLocation(String appName) {
232         AppInstanceController appInstanceController = runningApps.get(appName);
233         return appInstanceController == null ? null : appInstanceController.getCurrentLocation();
234     }
235 
236     /**
237      * Delegates the starting of an {@link App} to the {@link info.magnolia.ui.api.app.AppContext}. In
238      * case the app is already started, it will update its location.
239      */
240     private AppInstanceController doStartIfNotAlreadyRunning(AppInstanceController appInstanceController, Location location) {
241         if (isAppStarted(appInstanceController.getAppDescriptor().getName())) {
242             appInstanceController.onLocationUpdate(location);
243             sendEvent(AppLifecycleEventType.FOCUSED, appInstanceController.getAppDescriptor());
244             return appInstanceController;
245         }
246 
247         runningApps.put(appInstanceController.getAppDescriptor().getName(), appInstanceController);
248         appInstanceController.start(location);
249         sendEvent(AppLifecycleEventType.STARTED, appInstanceController.getAppDescriptor());
250         return appInstanceController;
251     }
252 
253     /**
254      * Focuses an already running {@link App} by passing it to the
255      * {@link LocationController}.
256      */
257     private void doFocus(AppInstanceController appInstanceController) {
258         locationController.goTo(appInstanceController.getCurrentLocation());
259         // focus event is further sent if re-focusing an already open app
260         // see #doStartIfNotAlreadyRunning
261     }
262 
263     private void doStop(AppInstanceController appInstanceController) {
264         final Location currentLocation = locationController.getWhere();
265 
266         sendEvent(AppLifecycleEventType.STOPPED, appInstanceController.getAppDescriptor());
267         appInstanceController.stop();
268         while (appHistory.remove(appInstanceController)) {
269             ;
270         }
271 
272         final String appName = appInstanceController.getAppDescriptor().getName();
273         runningApps.remove(appName);
274         appInstances.remove(appName);
275 
276         if (currentAppInstanceController == appInstanceController) {
277             currentAppInstanceController = null;
278             viewport.setView(null);
279         }
280 
281         if (!appHistory.isEmpty()) {
282             doFocus(appHistory.peekFirst());
283         } else if (!AdmincentralFlavour.get().isM5()) {
284             // last app was stopped?
285             locationController.goTo(new DefaultLocation("home"));
286         }
287 
288         if (AdmincentralFlavour.get().isM5() &&
289                 StringUtils.equalsIgnoreCase(appName, currentLocation.getAppName())) {
290             // Only 'fallback' to app launcher we are closing the currently visible app
291             locationController.goTo(new DefaultLocation(Location.LOCATION_TYPE_SHELL_APP, "applauncher"));
292         }
293     }
294 
295     private void sendEvent(AppLifecycleEventType appEventType, AppDescriptor appDescriptor) {
296         eventBus.fireEvent(new AppLifecycleEvent(appDescriptor, appEventType));
297     }
298 
299     /**
300      * Takes care of {@link LocationChangedEvent}s by:
301      * <ul>
302      * <li>Obtaining the {@link AppDescriptor} associated with the {@link Location}.</li>
303      * <li>Creating a new {@link info.magnolia.ui.api.app.AppContext} if not running, otherwise obtain it from the running apps.</li>
304      * <li>Updating the {@Link Location} and redirecting in case of missing subAppId.</li>
305      * <li>Starting the App.</li>
306      * <li>Adding the {@link info.magnolia.ui.api.app.AppContext} to the appHistory.</li>
307      * <li>Setting the viewport and updating the current running app.</li>
308      * </ul>
309      */
310     @Override
311     public void onLocationChanged(LocationChangedEvent event) {
312         Location newLocation = event.getNewLocation();
313 
314         // Whenever app-locations come with empty params and app is already running, attempt to restore them
315         // This may happen from client side history states (doesn't remember app parameters), or from loose app-targeted DefaultLocations
316         String actualParam = newLocation.getParameter();
317         if (StringUtils.isEmpty(actualParam) && newLocation instanceof DefaultLocation) {
318             Location lastAppLocation = getAppLocation(newLocation.getAppName());
319             if (lastAppLocation != null) {
320                 ((DefaultLocation) newLocation).setParameter(lastAppLocation.getParameter());
321             }
322         }
323 
324         if (locationIsEmpty(newLocation)) {
325             return;
326         }
327 
328         if (!newLocation.getAppType().equals(Location.LOCATION_TYPE_APP)) {
329             return;
330         }
331 
332         if (newLocation.equals(getCurrentAppLocation())) {
333             return;
334         }
335 
336         if (!isAllowedToUser(newLocation)) {
337             return;
338         }
339 
340         AppDescriptor nextApp = getAppForLocation(newLocation);
341         if (nextApp == null) {
342             return;
343         }
344 
345         AppInstanceController nextAppContext = getAppInstance(nextApp.getName());
346 
347         // update location
348         Location updateLocation = updateLocation(nextAppContext, newLocation);
349         if (!updateLocation.equals(newLocation)) {
350             locationController.goTo(updateLocation);
351             return;
352         }
353 
354         if (currentAppInstanceController != nextAppContext) {
355             appHistory.addFirst(nextAppContext);
356             currentAppInstanceController = nextAppContext;
357         }
358 
359         nextAppContext = doStartIfNotAlreadyRunning(nextAppContext, newLocation);
360 
361         try {
362             viewport.setView(nextAppContext.getApp().getView());
363         } catch (Exception e) {
364             log.error("App {} failed to start: {}", nextApp.getName(), e.getMessage(), e);
365             App failedApp = componentProvider.newInstance(FailedAppStub.class, nextAppContext, e);
366             failedApp.start(newLocation);
367             viewport.setView(failedApp.getView());
368         }
369     }
370 
371     /**
372      * Updates the {@link Location} in case of missing subAppId:
373      * <ul>
374      * <li>If the app is running, it will fetch the current Location associated with the App.</li>
375      * <li>Will fetch the configured default subAppId otherwise.</li>
376      * </ul>
377      */
378     private Location updateLocation(AppInstanceController appInstanceController, Location location) {
379         String appType = location.getAppType();
380         String appName = location.getAppName();
381         String subAppId = location.getSubAppId();
382         String params = location.getParameter();
383 
384         if (StringUtils.isBlank(subAppId)) {
385 
386             if (isAppStarted(appName)) {
387                 AppInstanceController runningAppContext = runningApps.get(appName);
388                 subAppId = runningAppContext.getCurrentLocation().getSubAppId();
389             } else if (StringUtils.isBlank(subAppId)) {
390                 Location defaultLocation = appInstanceController.getDefaultLocation();
391                 if (defaultLocation != null) {
392                     subAppId = defaultLocation.getSubAppId();
393                 } else {
394                     log.warn("No default location could be found for the '{}' app, please check subapp configuration.", appName);
395                 }
396 
397             }
398         }
399 
400         return new DefaultLocation(appType, appName, subAppId, params);
401     }
402 
403     private AppInstanceController getAppInstance(String appName) {
404         return appInstances.computeIfAbsent(appName, this::createNewAppInstance);
405     }
406 
407     private AppInstanceController createNewAppInstance(String appName) {
408         AppDescriptor descriptor = getAppDescriptor(appName);
409         if (descriptor == null) {
410             return null;
411         }
412 
413         AppInstanceController appInstanceController = componentProvider.newInstance(AppInstanceController.class, descriptor);
414         createAppComponentProvider(appInstanceController);
415 
416         return appInstanceController;
417     }
418 
419     @Override
420     public void onLocationChangeRequested(LocationChangeRequestedEvent event) {
421         if (currentAppInstanceController != null) {
422             final String message = currentAppInstanceController.mayStop();
423             if (message != null) {
424                 event.setWarning(message);
425             }
426         }
427     }
428 
429     @Override
430     public void openChooseDialog(String appName, UiContext uiContext, String selectedId, ChooseDialogCallback callback) {
431         openChooseDialog(appName, uiContext, null, selectedId, callback);
432     }
433 
434     @Override
435     public void openChooseDialog(String appName, UiContext uiContext, String targetTreeRootPath, String selectedId, ChooseDialogCallback callback) {
436         App targetApp = getAppWithoutStarting(appName);
437         if (targetApp != null) {
438             final ChooseDialogCallback composedCallback = ChooseDialogCallback.composeWith(targetApp::stop, callback);
439             if (StringUtils.isNotBlank(targetTreeRootPath)) {
440                 targetApp.openChooseDialog(uiContext, targetTreeRootPath, selectedId, composedCallback);
441             } else {
442                 targetApp.openChooseDialog(uiContext, selectedId, callback);
443             }
444         }
445     }
446 
447     private AppDescriptor getAppForLocation(Location location) {
448         return getAppDescriptor(location.getAppName());
449     }
450 
451     private AppDescriptor getAppDescriptor(String name) throws RuntimeException {
452         final DefinitionProvider<AppDescriptor> definitionProvider;
453         try {
454             definitionProvider = appDescriptorRegistry.getProvider(name);
455         } catch (Registry.NoSuchDefinitionException | IllegalStateException e) {
456             Message errorMessage = new Message();
457             errorMessage.setType(MessageType.ERROR);
458             errorMessage.setSubject(i18n.translate("ui-framework.app.appdescriptorReadError.subject"));
459             errorMessage.setMessage(String.format(i18n.translate("ui-framework.app.appdescriptorReadError.message"), name));
460             messagesManager.sendLocalMessage(errorMessage);
461             throw new RuntimeException(e);
462         }
463         return definitionProvider.get();
464     }
465 
466     private ComponentProvider createAppComponentProvider(AppInstanceController appInstanceController) {
467         final UiContextReference uiContextReference = UiContextReference.ofApp(appInstanceController);
468         final UiContextBoundComponentProvider appComponentProvider = new UiContextBoundComponentProvider(uiContextReference);
469         appInstanceController.setAppComponentProvider(appComponentProvider);
470 
471         final Map<Key, Object> initialScopedInstances = new HashMap<>();
472         initialScopedInstances.put(Key.get(AppContext.class), appInstanceController);
473 
474         SessionStore.access().createBeanStore(uiContextReference, initialScopedInstances);
475 
476         return appComponentProvider;
477     }
478 
479     private boolean locationIsEmpty(Location location) {
480         return Objects.equals(Location.NOWHERE, location) || (StringUtils.equals(Location.NOWHERE.getAppType(), location.getAppType())
481                 && StringUtils.equals(Location.NOWHERE.getAppName(), location.getAppName())
482                 && StringUtils.equals(Location.NOWHERE.getSubAppId(), location.getSubAppId())
483                 && StringUtils.equals(Location.NOWHERE.getParameter(), location.getParameter()));
484     }
485 
486     private boolean isAllowedToUser(final Location location) {
487         if (Location.LOCATION_TYPE_SHELL_APP.equals(location.getAppType())) {
488             return true;
489         }
490         final AppDescriptor appDescriptor = getAppDescriptor(location.getAppName());
491         final AccessDefinition permissions = appDescriptor.getPermissions();
492 
493         return appDescriptor.isEnabled() && (permissions == null || permissions.hasAccess(user));
494     }
495 }