aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/lib/Service
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/lib/Service')
-rw-r--r--apps/theming/lib/Service/BackgroundService.php414
-rw-r--r--apps/theming/lib/Service/JSDataService.php49
-rw-r--r--apps/theming/lib/Service/ThemeInjectionService.php116
-rw-r--r--apps/theming/lib/Service/ThemesService.php188
4 files changed, 767 insertions, 0 deletions
diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php
new file mode 100644
index 00000000000..ee9466c3a36
--- /dev/null
+++ b/apps/theming/lib/Service/BackgroundService.php
@@ -0,0 +1,414 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Theming\Service;
+
+use InvalidArgumentException;
+use OC\User\NoUserException;
+use OCA\Theming\AppInfo\Application;
+use OCP\Files\File;
+use OCP\Files\IAppData;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\Files\SimpleFS\ISimpleFolder;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\Image;
+use OCP\Lock\LockedException;
+use OCP\PreConditionNotMetException;
+use RuntimeException;
+
+class BackgroundService {
+ public const DEFAULT_COLOR = '#00679e';
+ public const DEFAULT_BACKGROUND_COLOR = '#00679e';
+
+ /**
+ * One of our shipped background images is used
+ */
+ public const BACKGROUND_SHIPPED = 'shipped';
+ /**
+ * A custom background image is used
+ */
+ public const BACKGROUND_CUSTOM = 'custom';
+ /**
+ * The default background image is used
+ */
+ public const BACKGROUND_DEFAULT = 'default';
+ /**
+ * Just a background color is used
+ */
+ public const BACKGROUND_COLOR = 'color';
+
+ public const DEFAULT_BACKGROUND_IMAGE = 'jenna-kim-the-globe.webp';
+
+ /**
+ * 'attribution': Name, artist and license
+ * 'description': Alternative text
+ * 'attribution_url': URL for attribution
+ * 'background_color': Cached mean color of the top part to calculate app menu colors and use as fallback
+ * 'primary_color': Recommended primary color for this theme / image
+ */
+ public const SHIPPED_BACKGROUNDS = [
+ 'jenna-kim-the-globe.webp' => [
+ 'attribution' => 'Globe (Jenna Kim - Nextcloud GmbH, CC-BY-SA-4.0)',
+ 'description' => 'Background picture of white clouds on in front of a blue sky',
+ 'attribution_url' => 'https://nextcloud.com/trademarks/',
+ 'dark_variant' => 'jenna-kim-the-globe-dark.webp',
+ 'background_color' => self::DEFAULT_BACKGROUND_COLOR,
+ 'primary_color' => self::DEFAULT_COLOR,
+ ],
+ 'kamil-porembinski-clouds.jpg' => [
+ 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)',
+ 'description' => 'Background picture of white clouds on in front of a blue sky',
+ 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/',
+ 'background_color' => self::DEFAULT_BACKGROUND_COLOR,
+ 'primary_color' => self::DEFAULT_COLOR,
+ ],
+ 'hannah-maclean-soft-floral.jpg' => [
+ 'attribution' => 'Soft floral (Hannah MacLean, CC0)',
+ 'description' => 'Abstract background picture in yellow and white color whith a flower on it',
+ 'attribution_url' => 'https://stocksnap.io/photo/soft-floral-XOYWCCW5PA',
+ 'background_color' => '#e4d2c1',
+ 'primary_color' => '#9f652f',
+ ],
+ 'ted-moravec-morning-fog.jpg' => [
+ 'attribution' => 'Morning fog (Ted Moravec, Public Domain)',
+ 'description' => 'Background picture of a forest shrouded in fog',
+ 'attribution_url' => 'https://flickr.com/photos/tmoravec/52392410261',
+ 'background_color' => '#f6f7f6',
+ 'primary_color' => '#114c3b',
+ ],
+ 'stefanus-martanto-setyo-husodo-underwater-ocean.jpg' => [
+ 'attribution' => 'Underwater ocean (Stefanus Martanto Setyo Husodo, CC0)',
+ 'description' => 'Background picture of an underwater ocean',
+ 'attribution_url' => 'https://stocksnap.io/photo/underwater-ocean-TJA9LBH4WS',
+ 'background_color' => '#003351',
+ 'primary_color' => '#04577e',
+ ],
+ 'zoltan-voros-rhythm-and-blues.jpg' => [
+ 'attribution' => 'Rhythm and blues (Zoltán Vörös, CC BY)',
+ 'description' => 'Abstract background picture of sand dunes during night',
+ 'attribution_url' => 'https://flickr.com/photos/v923z/51634409289/',
+ 'background_color' => '#1c2437',
+ 'primary_color' => '#1c243c',
+ ],
+ 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [
+ 'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)',
+ 'description' => 'Background picture of a red-ish butterfly wing under microscope',
+ 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:%D0%A7%D0%B5%D1%88%D1%83%D0%B9%D0%BA%D0%B8_%D0%BA%D1%80%D1%8B%D0%BB%D0%B0_%D0%B1%D0%B0%D0%B1%D0%BE%D1%87%D0%BA%D0%B8.jpg',
+ 'background_color' => '#652e11',
+ 'primary_color' => '#a53c17',
+ ],
+ 'bernie-cetonia-aurata-take-off-composition.jpg' => [
+ 'attribution' => 'Cetonia aurata take off composition (Bernie, Public Domain)',
+ 'description' => 'Montage of a cetonia aurata bug that takes off with white background',
+ 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Cetonia_aurata_take_off_composition_05172009.jpg',
+ 'background_color' => '#dee0d3',
+ 'primary_color' => '#56633d',
+ ],
+ 'dejan-krsmanovic-ribbed-red-metal.jpg' => [
+ 'attribution' => 'Ribbed red metal (Dejan Krsmanovic, CC BY)',
+ 'description' => 'Abstract background picture of red ribbed metal with two horizontal white elements on top of it',
+ 'attribution_url' => 'https://www.flickr.com/photos/dejankrsmanovic/42971456774/',
+ 'background_color' => '#9b171c',
+ 'primary_color' => '#9c4236',
+ ],
+ 'eduardo-neves-pedra-azul.jpg' => [
+ 'attribution' => 'Pedra azul milky way (Eduardo Neves, CC BY-SA)',
+ 'description' => 'Background picture of the milky way during night with a mountain in front of it',
+ 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Pedra_Azul_Milky_Way.jpg',
+ 'background_color' => '#1d242d',
+ 'primary_color' => '#4f6071',
+ ],
+ 'european-space-agency-barents-bloom.jpg' => [
+ 'attribution' => 'Barents bloom (European Space Agency, CC BY-SA)',
+ 'description' => 'Abstract background picture of blooming barents in blue and green colors',
+ 'attribution_url' => 'https://www.esa.int/ESA_Multimedia/Images/2016/08/Barents_bloom',
+ 'background_color' => '#1c383d',
+ 'primary_color' => '#396475',
+ ],
+ 'hannes-fritz-flippity-floppity.jpg' => [
+ 'attribution' => 'Flippity floppity (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Abstract background picture of many pairs of flip flops hanging on a wall in multiple colors',
+ 'attribution_url' => 'http://hannes.photos/flippity-floppity',
+ 'background_color' => '#5b2d53',
+ 'primary_color' => '#98415a',
+ ],
+ 'hannes-fritz-roulette.jpg' => [
+ 'attribution' => 'Roulette (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Background picture of a rotating giant wheel during night',
+ 'attribution_url' => 'http://hannes.photos/roulette',
+ 'background_color' => '#000000',
+ 'primary_color' => '#845334',
+ ],
+ 'hannes-fritz-sea-spray.jpg' => [
+ 'attribution' => 'Sea spray (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Background picture of a stone coast with fog and sea behind it',
+ 'attribution_url' => 'http://hannes.photos/sea-spray',
+ 'background_color' => '#333f47',
+ 'primary_color' => '#4f6071',
+ ],
+ 'bernard-spragg-new-zealand-fern.jpg' => [
+ 'attribution' => 'New zealand fern (Bernard Spragg, CC0)',
+ 'description' => 'Abstract background picture of fern leafes',
+ 'attribution_url' => 'https://commons.wikimedia.org/wiki/File:NZ_Fern.(Blechnum_chambersii)_(11263534936).jpg',
+ 'background_color' => '#0c3c03',
+ 'primary_color' => '#316b26',
+ ],
+ 'rawpixel-pink-tapioca-bubbles.jpg' => [
+ 'attribution' => 'Pink tapioca bubbles (Rawpixel, CC BY)',
+ 'description' => 'Abstract background picture of pink tapioca bubbles',
+ 'attribution_url' => 'https://www.flickr.com/photos/byrawpixel/27665140298/in/photostream/',
+ 'background_color' => '#c56e95',
+ 'primary_color' => '#7b4e7e',
+ ],
+ 'nasa-waxing-crescent-moon.jpg' => [
+ 'attribution' => 'Waxing crescent moon (NASA, Public Domain)',
+ 'description' => 'Background picture of glowing earth in foreground and moon in the background',
+ 'attribution_url' => 'https://www.nasa.gov/image-feature/a-waxing-crescent-moon',
+ 'background_color' => '#000002',
+ 'primary_color' => '#005ac1',
+ ],
+ 'tommy-chau-already.jpg' => [
+ 'attribution' => 'Cityscape (Tommy Chau, CC BY)',
+ 'description' => 'Background picture of a skyscraper city during night',
+ 'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/16910999368',
+ 'background_color' => '#35229f',
+ 'primary_color' => '#6a2af4',
+ ],
+ 'tommy-chau-lion-rock-hill.jpg' => [
+ 'attribution' => 'Lion rock hill (Tommy Chau, CC BY)',
+ 'description' => 'Background picture of mountains during sunset or sunrise',
+ 'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/17136440246',
+ 'background_color' => '#cb92b7',
+ 'primary_color' => '#7f4f70',
+ ],
+ 'lali-masriera-yellow-bricks.jpg' => [
+ 'attribution' => 'Yellow bricks (Lali Masriera, CC BY)',
+ 'description' => 'Background picture of yellow bricks with some yellow tubes',
+ 'attribution_url' => 'https://www.flickr.com/photos/visualpanic/3982464447',
+ 'background_color' => '#c78a19',
+ 'primary_color' => '#7f5700',
+ ],
+ ];
+
+ public function __construct(
+ private IRootFolder $rootFolder,
+ private IAppData $appData,
+ private IAppConfig $appConfig,
+ private IConfig $config,
+ private ?string $userId,
+ ) {
+ }
+
+ public function setDefaultBackground(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'background_image');
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'background_color');
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'primary_color');
+ }
+
+ /**
+ * @param $path
+ * @throws NotFoundException
+ * @throws NotPermittedException
+ * @throws LockedException
+ * @throws PreConditionNotMetException
+ * @throws NoUserException
+ */
+ public function setFileBackground(string $path, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+ $userFolder = $this->rootFolder->getUserFolder($userId);
+
+ /** @var File $file */
+ $file = $userFolder->get($path);
+ $handle = $file->fopen('r');
+ if ($handle === false) {
+ throw new InvalidArgumentException('Invalid image file');
+ }
+ $this->getAppDataFolder()->newFile('background.jpg', $handle);
+
+ $this->recalculateMeanColor();
+ }
+
+ public function recalculateMeanColor(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ $image = new Image();
+ $handle = $this->getAppDataFolder($userId)->getFile('background.jpg')->read();
+ if ($handle === false || $image->loadFromFileHandle($handle) === false) {
+ throw new InvalidArgumentException('Invalid image file');
+ }
+
+ $meanColor = $this->calculateMeanColor($image);
+ if ($meanColor !== false) {
+ $this->setColorBackground($meanColor);
+ }
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
+ }
+
+ /**
+ * Set background of user to a shipped background identified by the filename
+ * @param string $filename The shipped background filename
+ * @param null|string $userId The user to set - defaults to currently logged in user
+ * @throws RuntimeException If neither $userId is specified nor a user is logged in
+ * @throws InvalidArgumentException If the specified filename does not match any shipped background
+ */
+ public function setShippedBackground(string $filename, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ if (!array_key_exists($filename, self::SHIPPED_BACKGROUNDS)) {
+ throw new InvalidArgumentException('The given file name is invalid');
+ }
+ $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$filename]['background_color'], $userId);
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', $filename);
+ $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$filename]['primary_color']);
+ }
+
+ /**
+ * Set the background to color only
+ * @param string|null $userId The user to set the color - default to current logged-in user
+ */
+ public function setColorBackground(string $color, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
+ throw new InvalidArgumentException('The given color is invalid');
+ }
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_color', $color);
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
+ }
+
+ public function deleteBackgroundImage(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
+ }
+
+ public function getBackground(?string $userId = null): ?ISimpleFile {
+ $userId = $userId ?? $this->getUserId();
+ $background = $this->config->getUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
+ if ($background === self::BACKGROUND_CUSTOM) {
+ try {
+ return $this->getAppDataFolder()->getFile('background.jpg');
+ } catch (NotFoundException|NotPermittedException $e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Called when a new global background (backgroundMime) is uploaded (admin setting)
+ * This sets all necessary app config values
+ * @param resource|string $path
+ * @return string|null The fallback background color - if any
+ */
+ public function setGlobalBackground($path): ?string {
+ $image = new Image();
+ $handle = is_resource($path) ? $path : fopen($path, 'rb');
+
+ if ($handle && $image->loadFromFileHandle($handle) !== false) {
+ $meanColor = $this->calculateMeanColor($image);
+ if ($meanColor !== false) {
+ $this->appConfig->setValueString(Application::APP_ID, 'background_color', $meanColor);
+ return $meanColor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Calculate mean color of an given image
+ * It only takes the upper part into account so that a matching text color can be derived for the app menu
+ */
+ private function calculateMeanColor(Image $image): false|string {
+ /**
+ * Small helper to ensure one channel is returned as 8byte hex
+ */
+ function toHex(int $channel): string {
+ $hex = dechex($channel);
+ return match (strlen($hex)) {
+ 0 => '00',
+ 1 => '0' . $hex,
+ 2 => $hex,
+ default => 'ff',
+ };
+ }
+
+ $tempImage = new Image();
+
+ // Crop to only analyze top bar
+ $resource = $image->cropNew(0, 0, $image->width(), min(max(50, (int)($image->height() * 0.125)), $image->height()));
+ if ($resource === false) {
+ return false;
+ }
+
+ $tempImage->setResource($resource);
+ if (!$tempImage->preciseResize(100, 7)) {
+ return false;
+ }
+
+ $resource = $tempImage->resource();
+ if ($resource === false) {
+ return false;
+ }
+
+ $reds = [];
+ $greens = [];
+ $blues = [];
+ for ($y = 0; $y < 7; $y++) {
+ for ($x = 0; $x < 100; $x++) {
+ $value = imagecolorat($resource, $x, $y);
+ if ($value === false) {
+ continue;
+ }
+ $reds[] = ($value >> 16) & 0xFF;
+ $greens[] = ($value >> 8) & 0xFF;
+ $blues[] = $value & 0xFF;
+ }
+ }
+ $meanColor = '#' . toHex((int)(array_sum($reds) / count($reds)));
+ $meanColor .= toHex((int)(array_sum($greens) / count($greens)));
+ $meanColor .= toHex((int)(array_sum($blues) / count($blues)));
+ return $meanColor;
+ }
+
+ /**
+ * Storing the data in appdata/theming/users/USERID
+ *
+ * @param string|null $userId The user to get the folder - default to current user
+ * @throws NotPermittedException
+ */
+ private function getAppDataFolder(?string $userId = null): ISimpleFolder {
+ $userId = $userId ?? $this->getUserId();
+
+ try {
+ $rootFolder = $this->appData->getFolder('users');
+ } catch (NotFoundException) {
+ $rootFolder = $this->appData->newFolder('users');
+ }
+ try {
+ return $rootFolder->getFolder($userId);
+ } catch (NotFoundException) {
+ return $rootFolder->newFolder($userId);
+ }
+ }
+
+ /**
+ * @throws RuntimeException Thrown if a method that needs a user is called without any logged-in user
+ */
+ private function getUserId(): string {
+ if ($this->userId === null) {
+ throw new RuntimeException('No currently logged-in user');
+ }
+ return $this->userId;
+ }
+}
diff --git a/apps/theming/lib/Service/JSDataService.php b/apps/theming/lib/Service/JSDataService.php
new file mode 100644
index 00000000000..81198f8b3f5
--- /dev/null
+++ b/apps/theming/lib/Service/JSDataService.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Theming\Service;
+
+use OCA\Theming\ThemingDefaults;
+use OCA\Theming\Util;
+
+class JSDataService implements \JsonSerializable {
+
+ public function __construct(
+ private ThemingDefaults $themingDefaults,
+ private Util $util,
+ private ThemesService $themesService,
+ ) {
+ $this->themingDefaults = $themingDefaults;
+ $this->util = $util;
+ $this->themesService = $themesService;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'name' => $this->themingDefaults->getName(),
+ 'slogan' => $this->themingDefaults->getSlogan(),
+
+ 'url' => $this->themingDefaults->getBaseUrl(),
+ 'imprintUrl' => $this->themingDefaults->getImprintUrl(),
+ 'privacyUrl' => $this->themingDefaults->getPrivacyUrl(),
+
+ 'primaryColor' => $this->themingDefaults->getColorPrimary(),
+ 'backgroundColor' => $this->themingDefaults->getColorBackground(),
+ 'defaultPrimaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
+ 'defaultBackgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
+ 'inverted' => $this->util->invertTextColor($this->themingDefaults->getColorPrimary()),
+
+ 'cacheBuster' => $this->util->getCacheBuster(),
+ 'enabledThemes' => $this->themesService->getEnabledThemes(),
+
+ // deprecated use primaryColor
+ 'color' => $this->themingDefaults->getColorPrimary(),
+ '' => 'color is deprecated since Nextcloud 29, use primaryColor instead'
+ ];
+ }
+}
diff --git a/apps/theming/lib/Service/ThemeInjectionService.php b/apps/theming/lib/Service/ThemeInjectionService.php
new file mode 100644
index 00000000000..873d388081c
--- /dev/null
+++ b/apps/theming/lib/Service/ThemeInjectionService.php
@@ -0,0 +1,116 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Theming\Service;
+
+use OCA\Theming\ITheme;
+use OCA\Theming\Themes\DefaultTheme;
+use OCA\Theming\Util;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+
+class ThemeInjectionService {
+
+ private ?string $userId;
+
+ public function __construct(
+ private IURLGenerator $urlGenerator,
+ private ThemesService $themesService,
+ private DefaultTheme $defaultTheme,
+ private Util $util,
+ private IConfig $config,
+ IUserSession $userSession,
+ ) {
+ if ($userSession->getUser() !== null) {
+ $this->userId = $userSession->getUser()->getUID();
+ } else {
+ $this->userId = null;
+ }
+ }
+
+ public function injectHeaders(): void {
+ $themes = $this->themesService->getThemes();
+ $defaultTheme = $themes[$this->defaultTheme->getId()];
+ $mediaThemes = array_filter($themes, function ($theme) {
+ // Check if the theme provides a media query
+ return (bool)$theme->getMediaQuery();
+ });
+
+ // Default theme fallback
+ $this->addThemeHeaders($defaultTheme);
+
+ // Themes applied by media queries
+ foreach ($mediaThemes as $theme) {
+ $this->addThemeHeaders($theme, true, $theme->getMediaQuery());
+ }
+
+ // Themes
+ foreach ($this->themesService->getThemes() as $theme) {
+ // Ignore default theme as already processed first
+ if ($theme->getId() === $this->defaultTheme->getId()) {
+ continue;
+ }
+ $this->addThemeHeaders($theme, false);
+ }
+
+ // Meta headers
+ $this->addThemeMetaHeaders($themes);
+ }
+
+ /**
+ * Inject theme header into rendered page
+ *
+ * @param ITheme $theme the theme
+ * @param bool $plain request the :root syntax
+ * @param string $media media query to use in the <link> element
+ */
+ private function addThemeHeaders(ITheme $theme, bool $plain = true, ?string $media = null): void {
+ $linkToCSS = $this->urlGenerator->linkToRoute('theming.Theming.getThemeStylesheet', [
+ 'themeId' => $theme->getId(),
+ 'plain' => $plain,
+ 'v' => $this->util->getCacheBuster(),
+ ]);
+ \OCP\Util::addHeader('link', [
+ 'rel' => 'stylesheet',
+ 'media' => $media,
+ 'href' => $linkToCSS,
+ 'class' => 'theme'
+ ]);
+ }
+
+ /**
+ * Inject meta headers into rendered page
+ *
+ * @param ITheme[] $themes the theme
+ */
+ private function addThemeMetaHeaders(array $themes): void {
+ $metaHeaders = [];
+
+ // Meta headers
+ foreach ($this->themesService->getThemes() as $theme) {
+ if (!empty($theme->getMeta())) {
+ foreach ($theme->getMeta() as $meta) {
+ if (!isset($meta['name']) || !isset($meta['content'])) {
+ continue;
+ }
+
+ if (!isset($metaHeaders[$meta['name']])) {
+ $metaHeaders[$meta['name']] = [];
+ }
+ $metaHeaders[$meta['name']][] = $meta['content'];
+ }
+ }
+ }
+
+ foreach ($metaHeaders as $name => $content) {
+ \OCP\Util::addHeader('meta', [
+ 'name' => $name,
+ 'content' => join(' ', array_unique($content)),
+ ]);
+ }
+ }
+}
diff --git a/apps/theming/lib/Service/ThemesService.php b/apps/theming/lib/Service/ThemesService.php
new file mode 100644
index 00000000000..f49524cb62c
--- /dev/null
+++ b/apps/theming/lib/Service/ThemesService.php
@@ -0,0 +1,188 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Theming\Service;
+
+use OCA\Theming\AppInfo\Application;
+use OCA\Theming\ITheme;
+use OCA\Theming\Themes\DarkHighContrastTheme;
+use OCA\Theming\Themes\DarkTheme;
+use OCA\Theming\Themes\DefaultTheme;
+use OCA\Theming\Themes\DyslexiaFont;
+use OCA\Theming\Themes\HighContrastTheme;
+use OCA\Theming\Themes\LightTheme;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+
+class ThemesService {
+ /** @var ITheme[] */
+ private array $themesProviders;
+
+ public function __construct(
+ private IUserSession $userSession,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private DefaultTheme $defaultTheme,
+ LightTheme $lightTheme,
+ private DarkTheme $darkTheme,
+ HighContrastTheme $highContrastTheme,
+ DarkHighContrastTheme $darkHighContrastTheme,
+ DyslexiaFont $dyslexiaFont,
+ ) {
+
+ // Register themes
+ $this->themesProviders = [
+ $defaultTheme->getId() => $defaultTheme,
+ $lightTheme->getId() => $lightTheme,
+ $darkTheme->getId() => $darkTheme,
+ $highContrastTheme->getId() => $highContrastTheme,
+ $darkHighContrastTheme->getId() => $darkHighContrastTheme,
+ $dyslexiaFont->getId() => $dyslexiaFont,
+ ];
+ }
+
+ /**
+ * Get the list of all registered themes
+ *
+ * @return ITheme[]
+ */
+ public function getThemes(): array {
+ // Enforced theme if configured
+ $enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
+ if ($enforcedTheme !== '') {
+ if (!isset($this->themesProviders[$enforcedTheme])) {
+ $this->logger->error('Enforced theme not found', ['theme' => $enforcedTheme]);
+ return $this->themesProviders;
+ }
+
+ $defaultTheme = $this->themesProviders[$this->defaultTheme->getId()];
+ $darkTheme = $this->themesProviders[$this->darkTheme->getId()];
+ $theme = $this->themesProviders[$enforcedTheme];
+ return [
+ // Leave the default theme as a fallback
+ $defaultTheme->getId() => $defaultTheme,
+ // Make sure we also have the dark theme to allow apps
+ // to scope sections of their UI to the dark theme
+ $darkTheme->getId() => $darkTheme,
+ // Finally, the enforced theme
+ $theme->getId() => $theme,
+ ];
+ }
+
+ return $this->themesProviders;
+ }
+
+ /**
+ * Enable a theme for the logged-in user
+ *
+ * @param ITheme $theme the theme to enable
+ * @return string[] the enabled themes
+ */
+ public function enableTheme(ITheme $theme): array {
+ $themesIds = $this->getEnabledThemes();
+
+ // If already enabled, ignore
+ if (in_array($theme->getId(), $themesIds)) {
+ return $themesIds;
+ }
+
+ /** @var ITheme[] */
+ $themes = array_filter(array_map(function ($themeId) {
+ return $this->getThemes()[$themeId];
+ }, $themesIds));
+
+ // Filtering all themes with the same type
+ $filteredThemes = array_filter($themes, function (ITheme $t) use ($theme) {
+ return $theme->getType() === $t->getType();
+ });
+
+ // Retrieve IDs only
+ /** @var string[] */
+ $filteredThemesIds = array_map(function (ITheme $t) {
+ return $t->getId();
+ }, array_values($filteredThemes));
+
+ $enabledThemes = array_merge(array_diff($themesIds, $filteredThemesIds), [$theme->getId()]);
+ $this->setEnabledThemes($enabledThemes);
+
+ return $enabledThemes;
+ }
+
+ /**
+ * Disable a theme for the logged-in user
+ *
+ * @param ITheme $theme the theme to disable
+ * @return string[] the enabled themes
+ */
+ public function disableTheme(ITheme $theme): array {
+ $themesIds = $this->getEnabledThemes();
+
+ // If enabled, removing it
+ if (in_array($theme->getId(), $themesIds)) {
+ $enabledThemes = array_diff($themesIds, [$theme->getId()]);
+ $this->setEnabledThemes($enabledThemes);
+ return $enabledThemes;
+ }
+
+ return $themesIds;
+ }
+
+ /**
+ * Check whether a theme is enabled or not
+ * for the logged-in user
+ *
+ * @return bool
+ */
+ public function isEnabled(ITheme $theme): bool {
+ $user = $this->userSession->getUser();
+ if ($user instanceof IUser) {
+ // Using keys as it's faster
+ $themes = $this->getEnabledThemes();
+ return in_array($theme->getId(), $themes);
+ }
+ return false;
+ }
+
+ /**
+ * Get the list of all enabled themes IDs for the current user.
+ *
+ * @return string[]
+ */
+ public function getEnabledThemes(): array {
+ $enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ if ($enforcedTheme !== '') {
+ return [$enforcedTheme];
+ }
+ return [];
+ }
+
+ $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '["default"]'));
+ if ($enforcedTheme !== '') {
+ return array_merge([$enforcedTheme], $enabledThemes);
+ }
+
+ try {
+ return $enabledThemes;
+ } catch (\Exception $e) {
+ return [];
+ }
+ }
+
+ /**
+ * Set the list of enabled themes
+ * for the logged-in user
+ *
+ * @param string[] $themes the list of enabled themes IDs
+ */
+ private function setEnabledThemes(array $themes): void {
+ $user = $this->userSession->getUser();
+ $this->config->setUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', json_encode(array_values(array_unique($themes))));
+ }
+}