001/**
002 * Copyright (C) 2006-2025 Talend Inc. - www.talend.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.talend.sdk.component.server.service;
017
018import static java.util.Locale.ROOT;
019import static java.util.Optional.of;
020import static java.util.Optional.ofNullable;
021import static java.util.function.Function.identity;
022import static java.util.stream.Collectors.toList;
023
024import java.io.BufferedInputStream;
025import java.io.ByteArrayOutputStream;
026import java.io.IOException;
027import java.io.InputStream;
028import java.util.Collection;
029import java.util.List;
030import java.util.Optional;
031import java.util.concurrent.ConcurrentHashMap;
032import java.util.concurrent.ConcurrentMap;
033
034import javax.annotation.PostConstruct;
035import javax.enterprise.context.ApplicationScoped;
036import javax.inject.Inject;
037
038import org.talend.sdk.component.container.Container;
039import org.talend.sdk.component.server.configuration.ComponentServerConfiguration;
040
041import lombok.Data;
042import lombok.extern.slf4j.Slf4j;
043
044@Slf4j
045@ApplicationScoped
046public class IconResolver {
047
048    @Inject
049    private ComponentServerConfiguration componentServerConfiguration;
050
051    private boolean supportsSvg;
052
053    private String defaultTheme;
054
055    private Boolean isThemeSupported;
056
057    private Boolean isLegacyIconsSupported;
058
059    private List<String> patterns;
060
061    @PostConstruct
062    protected void init() {
063        supportsSvg = studioSupportsSvgOrTrue()
064                && componentServerConfiguration.getIconExtensions().stream().anyMatch(it -> it.endsWith(".svg"));
065        isThemeSupported = componentServerConfiguration.getSupportIconTheme();
066        isLegacyIconsSupported = componentServerConfiguration.getSupportLegacyIcons();
067        defaultTheme = componentServerConfiguration.getIconDefaultTheme();
068        patterns = isSupportsSvg() ? componentServerConfiguration.getIconExtensions()
069                : componentServerConfiguration
070                        .getIconExtensions()
071                        .stream()
072                        .filter(it -> !it.endsWith(".svg"))
073                        .collect(toList());
074        log.info("[IconResolver] SVG supported: {}, patterns: {}.", isSupportsSvg(), patterns);
075    }
076
077    private Boolean studioSupportsSvgOrTrue() {
078        if (System.getProperty("talend.studio.version") != null) {
079            return Boolean.getBoolean("talend.component.server.icon.svg.support");
080        }
081        return true;
082    }
083
084    protected boolean isSupportsSvg() {
085        return supportsSvg;
086    }
087
088    protected Collection<String> getExtensionPreferences() {
089        return patterns;
090    }
091
092    /**
093     * IMPORTANT: the strategy moved to the configuration, high level we want something in this spirit:
094     *
095     * The lookup strategy of an icon is the following one:
096     * 1. Check in the server classpath in icons/override/${icon}_icon32.png
097     * (optionally icons/override/${icon}.svg if in the preferences),
098     * 2. Check in the family classloader the following names ${icon}_icon32.png, icons/${icon}_icon32.png, ...
099     * 3. Check in the server classloader the following names ${icon}_icon32.png, icons/${icon}_icon32.png, ...
100     *
101     * This enables to
102     * 1. override properly the icons (1),
103     * 2. provide them in the family (2) and
104     * 3. fallback on built-in icons if needed (3).
105     *
106     * Theme and legacy icons support:
107     * If theme is activated the path will be prefixed by its theme: icons/dark/${icon}.svg, icons/light/${icon}.svg.
108     * Also, if icon is not found in theme and legacy icons support is activated, legacy icons lookup will be applied.
109     * Otherwise, theme deactivated, legacy icons lookup will be applied.
110     *
111     * @param container the component family container.
112     * @param icon the icon to look up.
113     * @param theme the theme of icon to look up.
114     * @return the icon if found.
115     */
116    public Icon resolve(final Container container, final String icon, final String theme) {
117        if (icon == null) {
118            return null;
119        }
120
121        Cache cache = container.get(Cache.class);
122        if (cache == null) {
123            synchronized (container) {
124                cache = container.get(Cache.class);
125                if (cache == null) {
126                    cache = new Cache();
127                    container.set(Cache.class, cache);
128                }
129            }
130        }
131        final ClassLoader appLoader = Thread.currentThread().getContextClassLoader();
132        if (isThemeSupported) {
133            final String appliedTheme = theme != null ? theme : defaultTheme;
134            final String themedIcon = appliedTheme + "/" + icon;
135            Icon themed = cache.icons
136                    .computeIfAbsent(themedIcon,
137                            k -> ofNullable(getOverridenIcon(icon, appLoader)
138                                    .orElseGet(() -> doLoad(container.getLoader(), themedIcon, appliedTheme)
139                                            .orElseGet(
140                                                    () -> doLoad(appLoader, themedIcon, appliedTheme).orElse(null)))))
141                    .orElse(null);
142            if (themed == null && isLegacyIconsSupported) {
143                themed = cache.icons
144                        .computeIfAbsent(icon,
145                                k -> ofNullable(doLoad(container.getLoader(), icon, "")
146                                        .orElseGet(() -> doLoad(appLoader, icon, "")
147                                                .orElse(null))))
148                        .orElse(null);
149            }
150            return themed;
151        } else {
152            return cache.icons
153                    .computeIfAbsent(icon,
154                            k -> ofNullable(getOverridenIcon(icon, appLoader)
155                                    .orElseGet(() -> doLoad(container.getLoader(), icon, "")
156                                            .orElseGet(() -> doLoad(appLoader, icon, "").orElse(null)))))
157                    .orElse(null);
158        }
159    }
160
161    private Optional<Icon> getOverridenIcon(final String icon, final ClassLoader appLoader) {
162        Icon result = null;
163        if (isSupportsSvg()) {
164            result = loadIcon(appLoader, "icons/override/" + icon + ".svg", "").orElse(null);
165        }
166        if (result == null) {
167            return loadIcon(appLoader, "icons/override/" + icon + "_icon32.png", "");
168        }
169        return of(result);
170    }
171
172    public Optional<Icon> doLoad(final ClassLoader loader, final String icon, final String theme) {
173        return getExtensionPreferences()
174                .stream()
175                .map(ext -> String.format(ext, icon))
176                .map(path -> loadIcon(loader, path, theme))
177                .filter(Optional::isPresent)
178                .findFirst()
179                .flatMap(identity());
180    }
181
182    private Optional<Icon> loadIcon(final ClassLoader loader, final String path, final String theme) {
183        return ofNullable(loader.getResourceAsStream(path))
184                .map(resource -> new Icon(getType(path.toLowerCase(ROOT)), toBytes(resource), theme));
185    }
186
187    private String getType(final String path) {
188        if (path.endsWith(".png")) {
189            return "image/png";
190        }
191        if (path.endsWith(".svg")) {
192            return "image/svg+xml";
193        }
194        throw new IllegalArgumentException("Unsupported icon type: " + path);
195    }
196
197    public static class Cache {
198
199        private final ConcurrentMap<String, Optional<Icon>> icons = new ConcurrentHashMap<>();
200    }
201
202    @Data
203    public static class Icon {
204
205        private final String type;
206
207        private final byte[] bytes;
208
209        private final String theme;
210    }
211
212    private byte[] toBytes(final InputStream resource) {
213        try (final BufferedInputStream stream = new BufferedInputStream(resource)) {
214            final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(stream.available());
215            final byte[] buffer = new byte[1024];
216            int read;
217            while ((read = stream.read(buffer, 0, buffer.length)) >= 0) {
218                if (read > 0) {
219                    byteArrayOutputStream.write(buffer, 0, read);
220                }
221            }
222            return byteArrayOutputStream.toByteArray();
223        } catch (final IOException e) {
224            throw new IllegalStateException(e);
225        }
226    }
227}