001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2016, 2018  Michael N. Lipp
004 *
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.osgi.portlets.upnpbrowser;
020
021import freemarker.core.ParseException;
022import freemarker.template.MalformedTemplateNameException;
023import freemarker.template.Template;
024import freemarker.template.TemplateNotFoundException;
025
026import java.io.IOException;
027import java.io.InputStreamReader;
028import java.io.Reader;
029import java.io.Serializable;
030import java.time.Instant;
031import java.util.ArrayList;
032import java.util.Arrays;
033import java.util.Collections;
034import java.util.Comparator;
035import java.util.HashMap;
036import java.util.List;
037import java.util.Map;
038import java.util.Optional;
039import java.util.ResourceBundle;
040import java.util.Set;
041import java.util.stream.Collectors;
042
043import org.jdrupes.httpcodec.types.Converters;
044import org.jdrupes.httpcodec.types.MediaType;
045import org.jgrapes.core.Channel;
046import org.jgrapes.core.Components;
047import org.jgrapes.core.Event;
048import org.jgrapes.core.Manager;
049import org.jgrapes.core.annotation.Handler;
050import org.jgrapes.http.Session;
051import org.jgrapes.io.IOSubchannel;
052import org.jgrapes.portal.base.PortalSession;
053import org.jgrapes.portal.base.PortalUtils;
054import org.jgrapes.portal.base.Portlet.RenderMode;
055import static org.jgrapes.portal.base.Portlet.RenderMode.DeleteablePreview;
056import static org.jgrapes.portal.base.Portlet.RenderMode.View;
057import org.jgrapes.portal.base.RenderSupport;
058import org.jgrapes.portal.base.ResourceByInputStream;
059import org.jgrapes.portal.base.ResourceNotModified;
060import org.jgrapes.portal.base.events.AddPageResources.ScriptResource;
061import org.jgrapes.portal.base.events.AddPortletRequest;
062import org.jgrapes.portal.base.events.AddPortletType;
063import org.jgrapes.portal.base.events.DeletePortlet;
064import org.jgrapes.portal.base.events.DeletePortletRequest;
065import org.jgrapes.portal.base.events.NotifyPortletView;
066import org.jgrapes.portal.base.events.PortalReady;
067import org.jgrapes.portal.base.events.PortletResourceRequest;
068import org.jgrapes.portal.base.events.RenderPortletRequest;
069import org.jgrapes.portal.base.events.RenderPortletRequestBase;
070import org.jgrapes.portal.base.freemarker.FreeMarkerPortlet;
071import org.osgi.framework.BundleContext;
072import org.osgi.framework.Constants;
073import org.osgi.framework.InvalidSyntaxException;
074import org.osgi.framework.ServiceEvent;
075import org.osgi.framework.ServiceListener;
076import org.osgi.framework.ServiceReference;
077import org.osgi.service.component.runtime.ServiceComponentRuntime;
078import org.osgi.service.upnp.UPnPDevice;
079import org.osgi.service.upnp.UPnPIcon;
080
081/**
082 * A portlet for inspecting the services in an OSGi runtime.
083 */
084@SuppressWarnings({ "PMD.ExcessiveImports" })
085public class UPnPBrowserPortlet extends FreeMarkerPortlet
086        implements ServiceListener {
087
088    private static final Set<RenderMode> MODES = RenderMode.asSet(
089        DeleteablePreview, View);
090    private final BundleContext context;
091
092    /**
093     * Creates a new component with its channel set to the given channel.
094     * 
095     * @param componentChannel the channel that the component's handlers listen
096     *            on by default and that {@link Manager#fire(Event, Channel...)}
097     *            sends the event to
098     */
099    @SuppressWarnings("PMD.UnusedFormalParameter")
100    public UPnPBrowserPortlet(Channel componentChannel, BundleContext context,
101            ServiceComponentRuntime scr) {
102        super(componentChannel, true);
103        this.context = context;
104    }
105
106    /**
107     * On {@link PortalReady}, fire the {@link AddPortletType}.
108     *
109     * @param event the event
110     * @param channel the channel
111     * @throws TemplateNotFoundException the template not found exception
112     * @throws MalformedTemplateNameException the malformed template name
113     *             exception
114     * @throws ParseException the parse exception
115     * @throws IOException Signals that an I/O exception has occurred.
116     */
117    @Handler
118    public void onPortalReady(PortalReady event, PortalSession channel)
119            throws TemplateNotFoundException, MalformedTemplateNameException,
120            ParseException, IOException {
121        ResourceBundle resourceBundle = resourceBundle(channel.locale());
122        Reader deviceTemplate = new InputStreamReader(UPnPBrowserPortlet.class
123            .getResourceAsStream("device-tree-template.html"));
124        // Add portlet resources to page
125        channel.respond(new AddPortletType(type())
126            .setDisplayName(resourceBundle.getString("portletName"))
127            .addScript(new ScriptResource()
128                .setRequires(new String[] { "vuejs.org" })
129                .setScriptUri(event.renderSupport().portletResource(
130                    type(), "UPnPBrowser-functions.ftl.js")))
131            .addScript(
132                new ScriptResource()
133                    .setScriptId("upnpbrowser-device-tree-template")
134                    .setScriptType("text/x-template")
135                    .loadScriptSource(deviceTemplate))
136            .addCss(event.renderSupport(),
137                PortalUtils.uriFromPath("UPnPBrowser-style.css"))
138            .setInstantiable());
139    }
140
141    /*
142     * (non-Javadoc)
143     * 
144     * @see org.jgrapes.portal.AbstractPortlet#generatePortletId()
145     */
146    @Override
147    protected String generatePortletId() {
148        return type() + "-" + super.generatePortletId();
149    }
150
151    /*
152     * (non-Javadoc)
153     * 
154     * @see org.jgrapes.portal.AbstractPortlet#modelFromSession
155     */
156    @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
157    @Override
158    protected <T extends Serializable> Optional<T> stateFromSession(
159            Session session, String portletId, Class<T> type) {
160        if (portletId.startsWith(type() + "-")) {
161            return Optional.of((T) new UPnPBrowserModel(portletId));
162        }
163        return Optional.empty();
164    }
165
166    /*
167     * (non-Javadoc)
168     * 
169     * @see org.jgrapes.portal.AbstractPortlet#doAddPortlet
170     */
171    @Override
172    protected String doAddPortlet(AddPortletRequest event,
173            PortalSession channel)
174            throws Exception {
175        UPnPBrowserModel portletModel
176            = new UPnPBrowserModel(generatePortletId());
177        renderPortlet(event, channel, portletModel);
178        return portletModel.getPortletId();
179    }
180
181    /*
182     * (non-Javadoc)
183     * 
184     * @see org.jgrapes.portal.AbstractPortlet#doRenderPortlet
185     */
186    @Override
187    protected void doRenderPortlet(RenderPortletRequest event,
188            PortalSession channel, String portletId,
189            Serializable retrievedState)
190            throws Exception {
191        UPnPBrowserModel portletModel = (UPnPBrowserModel) retrievedState;
192        renderPortlet(event, channel, portletModel);
193    }
194
195    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals",
196        "PMD.DataflowAnomalyAnalysis", "unchecked" })
197    private void renderPortlet(RenderPortletRequestBase<?> event,
198            PortalSession channel,
199            UPnPBrowserModel portletModel) throws TemplateNotFoundException,
200            MalformedTemplateNameException, ParseException, IOException,
201            InvalidSyntaxException {
202        switch (event.renderMode()) {
203        case Preview:
204        case DeleteablePreview: {
205            Template tpl = freemarkerConfig()
206                .getTemplate("UPnPBrowser-preview.ftl.html");
207            channel.respond(new RenderPortletFromTemplate(event,
208                UPnPBrowserPortlet.class, portletModel.getPortletId(),
209                tpl, fmModel(event, channel, portletModel))
210                    .setRenderMode(DeleteablePreview).setSupportedModes(MODES)
211                    .setForeground(event.isForeground()));
212            List<Map<String, Object>> deviceInfos = Arrays.stream(
213                context.getAllServiceReferences(UPnPDevice.class.getName(),
214                    "(!(" + UPnPDevice.PARENT_UDN + "=*))"))
215                .map(svc -> createDeviceInfo(context,
216                    (ServiceReference<UPnPDevice>) svc, event.renderSupport()))
217                .collect(Collectors.toList());
218            channel.respond(new NotifyPortletView(type(),
219                portletModel.getPortletId(), "deviceUpdates", deviceInfos,
220                "preview", true));
221            break;
222        }
223        case View: {
224            Template tpl
225                = freemarkerConfig().getTemplate("UPnPBrowser-view.ftl.html");
226            channel.respond(new RenderPortletFromTemplate(event,
227                UPnPBrowserPortlet.class, portletModel.getPortletId(),
228                tpl, fmModel(event, channel, portletModel))
229                    .setSupportedModes(MODES)
230                    .setForeground(event.isForeground()));
231            @SuppressWarnings("PMD.UseConcurrentHashMap")
232            Map<String, Map<String, Object>> deviceInfos = new HashMap<>();
233            Arrays.stream(context
234                .getAllServiceReferences(UPnPDevice.class.getName(), null))
235                .map(svc -> createDeviceInfo(context,
236                    (ServiceReference<UPnPDevice>) svc, event.renderSupport()))
237                .forEach(devInfo -> deviceInfos.put((String) devInfo.get("udn"),
238                    devInfo));
239            channel.respond(new NotifyPortletView(type(),
240                portletModel.getPortletId(), "deviceUpdates",
241                treeify(deviceInfos), "view", true));
242            break;
243        }
244        default:
245            break;
246        }
247    }
248
249    @SuppressWarnings({ "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" })
250    private Map<String, Object> createDeviceInfo(BundleContext context,
251            ServiceReference<UPnPDevice> deviceRef,
252            RenderSupport renderSupport) {
253        UPnPDevice device = context.getService(deviceRef);
254        if (device == null) {
255            return null;
256        }
257        try {
258            @SuppressWarnings("PMD.UseConcurrentHashMap")
259            Map<String, Object> result = new HashMap<>();
260            result.put("udn", (String) deviceRef.getProperty(UPnPDevice.UDN));
261            result.computeIfAbsent("parentUdn",
262                k -> (String) deviceRef.getProperty(UPnPDevice.PARENT_UDN));
263            result.put("friendlyName",
264                deviceRef.getProperty(UPnPDevice.FRIENDLY_NAME));
265            if (device.getIcons(null) != null) {
266                result.put("iconUrl", PortalUtils.mergeQuery(
267                    renderSupport.portletResource(type(), ""),
268                    Components.mapOf("udn", (String) deviceRef
269                        .getProperty(UPnPDevice.UDN), "resource", "icon"))
270                    .toASCIIString());
271            }
272            return result;
273        } finally {
274            context.ungetService(deviceRef);
275        }
276    }
277
278    @SuppressWarnings({ "unchecked", "PMD.AvoidInstantiatingObjectsInLoops" })
279    private List<Map<String, Object>>
280            treeify(Map<String, Map<String, Object>> deviceInfos) {
281        for (Map.Entry<String, Map<String, Object>> e : deviceInfos
282            .entrySet()) {
283            Optional.ofNullable(e.getValue().get("parentUdn")).ifPresent(
284                parentUdn -> ((List<Map<String, Object>>) deviceInfos
285                    .get(parentUdn).computeIfAbsent("childDevices",
286                        k -> new ArrayList<Map<String, Object>>()))
287                            .add(e.getValue()));
288        }
289        return deviceInfos.values().stream()
290            .filter(deviceInfo -> !deviceInfo.containsKey("parentUdn"))
291            .collect(Collectors.toList());
292    }
293
294    @Override
295    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" })
296    protected void doGetResource(PortletResourceRequest event,
297            IOSubchannel channel) {
298        Map<String, List<String>> query
299            = PortalUtils.queryAsMap(event.resourceUri());
300        if (!query.containsKey("udn")) {
301            super.doGetResource(event, channel);
302            return;
303        }
304        try {
305            Arrays.stream(context.getAllServiceReferences(null,
306                String.format("(&(%s=%s)(%s=%s))", Constants.OBJECTCLASS,
307                    UPnPDevice.class.getName(), UPnPDevice.UDN,
308                    query.get("udn").get(0))))
309                .findFirst().ifPresent(deviceRef -> {
310                    @SuppressWarnings("unchecked")
311                    UPnPDevice device = context
312                        .getService((ServiceReference<UPnPDevice>) deviceRef);
313                    if (query.getOrDefault("resource", Collections.emptyList())
314                        .contains("icon")) {
315                        provideIcon(event, device);
316                    }
317                });
318        } catch (InvalidSyntaxException e) {
319            throw new IllegalArgumentException(e);
320        }
321    }
322
323    @SuppressWarnings({ "PMD.EmptyCatchBlock", "PMD.DataflowAnomalyAnalysis" })
324    private void provideIcon(PortletResourceRequest event, UPnPDevice device) {
325        UPnPIcon[] icons = device
326            .getIcons(event.session().locale().toLanguageTag());
327        if (icons == null) {
328            icons = device.getIcons(null);
329        }
330        if (icons == null) {
331            return;
332        }
333        Arrays.sort(icons,
334            Comparator.comparingInt(UPnPIcon::getHeight).reversed());
335        UPnPIcon icon = icons[0];
336        try {
337            if (event.ifModifiedSince().isPresent()) {
338                event.setResult(new ResourceNotModified(event, Instant.now(),
339                    365 * 24 * 3600));
340            } else {
341                MediaType mediaType = null;
342                if (icon.getMimeType() != null
343                    && !icon.getMimeType().isEmpty()) {
344                    mediaType
345                        = Converters.MEDIA_TYPE
346                            .fromFieldValue(icon.getMimeType());
347                }
348                event.setResult(new ResourceByInputStream(event,
349                    icon.getInputStream(), mediaType, Instant.now(),
350                    365 * 24 * 3600));
351            }
352            event.stop();
353        } catch (IOException | java.text.ParseException e) {
354            // Handle as if no match
355        }
356    }
357
358    /**
359     * Translates the OSGi {@link ServiceEvent} to a JGrapes event and fires it
360     * on all known portal session channels.
361     *
362     * @param event the event
363     */
364    @Override
365    public void serviceChanged(ServiceEvent event) {
366        // TODO
367    }
368
369    /*
370     * (non-Javadoc)
371     * 
372     * @see org.jgrapes.portal.AbstractPortlet#doDeletePortlet
373     */
374    @Override
375    protected void doDeletePortlet(DeletePortletRequest event,
376            PortalSession channel,
377            String portletId, Serializable portletState) throws Exception {
378        channel.respond(new DeletePortlet(portletId));
379    }
380
381    /**
382     * The portlet's model.
383     */
384    @SuppressWarnings("serial")
385    public class UPnPBrowserModel extends PortletBaseModel {
386
387        /**
388         * Instantiates a new service list model.
389         *
390         * @param portletId the portlet id
391         */
392        public UPnPBrowserModel(String portletId) {
393            super(portletId);
394        }
395
396    }
397}