Merge pull request #60066 from nextcloud/fix/type-app-info

fix(appinfo): properly type info parser and app manager for appinfo.xml types
This commit is contained in:
Ferdinand Thiessen 2026-05-13 11:22:58 +02:00 committed by GitHub
commit e371c5d69a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1524 additions and 112 deletions

View file

@ -5,7 +5,7 @@
- SPDX-License-Identifier: AGPL-3.0-only
-->
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
xsi:noNamespaceSchemaLocation="../../../resources/app-info-shipped.xsd">
<id>files_sharing</id>
<name>File sharing</name>
<summary>File sharing</summary>
@ -86,11 +86,11 @@ Turning the feature off removes shared files and folders on the server for all s
</plugins>
</collaboration>
<public>
<files>public.php</files>
</public>
<openmetrics>
<exporter>OCA\Files_Sharing\OpenMetrics\SharesCountMetric</exporter>
</openmetrics>
<public>
<files>public.php</files>
</public>
</info>

View file

@ -136,7 +136,7 @@ class APIController extends OCSController {
* @return UpdateNotificationApp
*/
protected function getAppDetails(string $appId): array {
$app = $this->appManager->getAppInfo($appId, false, $this->language);
$app = $this->appManager->getAppInfo($appId, lang: $this->language ?? 'en');
$name = $app['name'] ?? $appId;
return [
'appId' => $appId,

View file

@ -150,6 +150,7 @@ return array(
'OCP\\AppFramework\\Services\\InitialStateProvider' => $baseDir . '/lib/public/AppFramework/Services/InitialStateProvider.php',
'OCP\\AppFramework\\Utility\\IControllerMethodReflector' => $baseDir . '/lib/public/AppFramework/Utility/IControllerMethodReflector.php',
'OCP\\AppFramework\\Utility\\ITimeFactory' => $baseDir . '/lib/public/AppFramework/Utility/ITimeFactory.php',
'OCP\\App\\AppInfoDefinition' => $baseDir . '/lib/public/App/AppInfoDefinition.php',
'OCP\\App\\AppPathNotFoundException' => $baseDir . '/lib/public/App/AppPathNotFoundException.php',
'OCP\\App\\Events\\AppDisableEvent' => $baseDir . '/lib/public/App/Events/AppDisableEvent.php',
'OCP\\App\\Events\\AppEnableEvent' => $baseDir . '/lib/public/App/Events/AppEnableEvent.php',

View file

@ -191,6 +191,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\AppFramework\\Services\\InitialStateProvider' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Services/InitialStateProvider.php',
'OCP\\AppFramework\\Utility\\IControllerMethodReflector' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Utility/IControllerMethodReflector.php',
'OCP\\AppFramework\\Utility\\ITimeFactory' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Utility/ITimeFactory.php',
'OCP\\App\\AppInfoDefinition' => __DIR__ . '/../../..' . '/lib/public/App/AppInfoDefinition.php',
'OCP\\App\\AppPathNotFoundException' => __DIR__ . '/../../..' . '/lib/public/App/AppPathNotFoundException.php',
'OCP\\App\\Events\\AppDisableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppDisableEvent.php',
'OCP\\App\\Events\\AppEnableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppEnableEvent.php',

View file

@ -38,6 +38,9 @@ use OCP\ServerVersion;
use OCP\Settings\IManager as ISettingsManager;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type AppInfoDefinition from \OCP\App\AppInfoDefinition
*/
class AppManager implements IAppManager {
/**
* Apps with these types can not be enabled for certain groups only
@ -63,7 +66,7 @@ class AppManager implements IAppManager {
private array $alwaysEnabled = [];
private array $defaultEnabled = [];
/** @var array */
/** @var array<string, AppInfoDefinition|null> */
private array $appInfos = [];
/** @var array */
@ -557,8 +560,8 @@ class AppManager implements IAppManager {
if (!empty($info['collaboration']['plugins'])) {
// deal with one or many plugin entries
$plugins = isset($info['collaboration']['plugins']['plugin']['@value'])
? [$info['collaboration']['plugins']['plugin']] : $info['collaboration']['plugins']['plugin'];
$plugins = isset($info['collaboration']['plugins']['@value'])
? [$info['collaboration']['plugins']] : $info['collaboration']['plugins'];
$collaboratorSearch = null;
$autoCompleteManager = null;
foreach ($plugins as $plugin) {
@ -841,14 +844,8 @@ class AppManager implements IAppManager {
return $appsToUpgrade;
}
/**
* Returns the app information from "appinfo/info.xml".
*
* @param string|null $lang
* @return array|null app info
*/
#[\Override]
public function getAppInfo(string $appId, bool $path = false, $lang = null) {
public function getAppInfo(string $appId, bool $path = false, $lang = null): ?array {
if ($path) {
throw new \InvalidArgumentException('Calling IAppManager::getAppInfo() with a path is no longer supported. Please call IAppManager::getAppInfoByPath() instead and verify that the path is good before calling.');
}
@ -880,7 +877,7 @@ class AppManager implements IAppManager {
$parser = new InfoParser($this->memCacheFactory->createLocal('core.appinfo'));
$data = $parser->parse($path);
if (is_array($data)) {
if ($lang !== null && is_array($data)) {
$data = $parser->applyL10N($data, $lang);
}

View file

@ -10,6 +10,11 @@ namespace OC\App;
use OCP\ICache;
use function simplexml_load_string;
/**
* @psalm-import-type AppInfoLocalizedEntry from \OCP\App\AppInfoDefinition
* @psalm-import-type AppInfoXmlDefinition from \OCP\App\AppInfoDefinition
* @psalm-import-type AppInfoDefinition from \OCP\App\AppInfoDefinition
*/
class InfoParser {
/**
* @param ICache|null $cache
@ -21,15 +26,15 @@ class InfoParser {
/**
* @param string $file the xml file to be loaded
* @return null|array where null is an indicator for an error
* @return AppInfoXmlDefinition|null - The parsed app info or null if an error occurred
*/
public function parse(string $file): ?array {
if (!file_exists($file)) {
return null;
}
$fileCacheKey = $file . filemtime($file);
if ($this->cache !== null) {
$fileCacheKey = $file . filemtime($file);
if ($cachedValue = $this->cache->get($fileCacheKey)) {
return json_decode($cachedValue, true);
}
@ -42,14 +47,14 @@ class InfoParser {
libxml_clear_errors();
return null;
}
$array = $this->xmlToArray($xml);
$array = $this->xmlToArray($xml);
if (is_string($array)) {
return null;
}
if (!array_key_exists('info', $array)) {
$array['info'] = [];
if (!array_key_exists('description', $array)) {
$array['description'] = '';
}
if (!array_key_exists('remote', $array)) {
$array['remote'] = [];
@ -166,11 +171,8 @@ class InfoParser {
if (isset($array['activity']['providers']['provider']) && is_array($array['activity']['providers']['provider'])) {
$array['activity']['providers'] = $array['activity']['providers']['provider'];
}
if (isset($array['collaboration']['collaborators']['searchPlugins']['searchPlugin'])
&& is_array($array['collaboration']['collaborators']['searchPlugins']['searchPlugin'])
&& !isset($array['collaboration']['collaborators']['searchPlugins']['searchPlugin']['class'])
) {
$array['collaboration']['collaborators']['searchPlugins'] = $array['collaboration']['collaborators']['searchPlugins']['searchPlugin'];
if (isset($array['collaboration']['plugins']['plugin']) && is_array($array['collaboration']['plugins']['plugin'])) {
$array['collaboration']['plugins'] = $array['collaboration']['plugins']['plugin'];
}
if (isset($array['settings']['admin']) && !is_array($array['settings']['admin'])) {
$array['settings']['admin'] = [$array['settings']['admin']];
@ -211,6 +213,9 @@ class InfoParser {
$array['category'] = [$array['category']];
}
/**
* @var AppInfoXmlDefinition $array
*/
if ($this->cache !== null) {
$this->cache->set($fileCacheKey, json_encode($array));
}
@ -283,27 +288,42 @@ class InfoParser {
/**
* Select the appropriate l10n version for fields name, summary and description
*
* @param AppInfoXmlDefinition $data
* @return AppInfoDefinition
*/
public function applyL10N(array $data, ?string $lang = null): array {
if ($lang !== '' && $lang !== null) {
if (isset($data['name']) && is_array($data['name'])) {
$data['name'] = $this->findBestL10NOption($data['name'], $lang);
}
if (isset($data['summary']) && is_array($data['summary'])) {
$data['summary'] = $this->findBestL10NOption($data['summary'], $lang);
}
if (isset($data['description']) && is_array($data['description'])) {
$data['description'] = trim($this->findBestL10NOption($data['description'], $lang));
}
} elseif (isset($data['description']) && is_string($data['description'])) {
$data['description'] = trim($data['description']);
} else {
$data['description'] = '';
public function applyL10N(array $data, string $lang): array {
// Ensure name is set and convert arrays to strings
if (!isset($data['name'])) {
$data['name'] = '';
} elseif (is_array($data['name'])) {
$data['name'] = $this->findBestL10NOption($data['name'], $lang);
}
$data['name'] = trim((string)$data['name']);
if (!isset($data['summary'])) {
$data['summary'] = '';
} elseif (is_array($data['summary'])) {
$data['summary'] = $this->findBestL10NOption($data['summary'], $lang);
}
$data['summary'] = trim((string)$data['summary']);
// Ensure description is set and convert arrays to strings
if (!isset($data['description'])) {
$data['description'] = '';
} elseif (is_array($data['description'])) {
$data['description'] = trim($this->findBestL10NOption($data['description'], $lang));
}
$data['description'] = trim((string)$data['description']);
return $data;
}
/**
* @param AppInfoLocalizedEntry|list<string|AppInfoLocalizedEntry> $options - The available l10n options for a field
* @param string $lang - The desired language code
* @return string - The best matching l10n option for the given language
*/
protected function findBestL10NOption(array $options, string $lang): string {
// only a single option
if (isset($options['@value'])) {

View file

@ -470,9 +470,8 @@ class OC_App {
}
}
$info['license'] ??= $info['licence'];
$info['license'] = $info['licence'];
$info['version'] = $appManager->getAppVersion($app);
$info['license'] ??= $info['licence'];
$appList[] = $info;
}
}

View file

@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\App;
/**
* @psalm-type AppInfoLocalizedEntry = array{
* '@attributes'?: array{
* lang?: non-empty-string,
* },
* '@value': string,
* }
*
* @psalm-type AppInfoLocalizedData = array{
* 'name': string,
* 'summary': string,
* 'description': string,
* }
*
* @psalm-type AppInfoRawXmlData = array{
* 'name': string|AppInfoLocalizedEntry|list<string|AppInfoLocalizedEntry>,
* 'summary': string|AppInfoLocalizedEntry|list<string|AppInfoLocalizedEntry>,
* 'description': string|AppInfoLocalizedEntry|list<string|AppInfoLocalizedEntry>,
* }
*
* The enum definitions as per info.xsd:
*
* @psalm-type AppInfoFieldTypeArchitecture = 'x86'|'x86_64'|'aarch'|'aarch64'
* @psalm-type AppInfoFieldTypeBits = 64|32
* @psalm-type AppInfoFieldTypeCategory = 'dashboard'|'security'|'customization'|'files'|'integration'|'monitoring'|'multimedia'|'office'|'organization'|'social'|'tools'|'games'|'search'|'workflow'|'ai'
* @psalm-type AppInfoFieldTypeCollaborationPluginType = 'collaborator-search'|'autocomplete-sort'
* @psalm-type AppInfoFieldTypeDatabases = 'sqlite'|'mysql'|'pgsql'
* @psalm-type AppInfoFieldTypeDonationPlatform = 'paypal'|'stripe'|'other'
* @psalm-type AppInfoFieldTypeLicense = 'AGPL-3.0-only'|'AGPL-3.0-or-later'|'Apache-2.0'|'GPL-3.0-only'|'GPL-3.0-or-later'|'MIT'|'MPL-2.0'|'agpl'|'mit'|'mpl'|'apache'|'gpl3'
* @psalm-type AppInfoFieldTypeNavigationType = 'link'|'settings'
* @psalm-type AppInfoFieldTypeNavigationRole = 'all'|'admin'
* @psalm-type AppInfoFieldTypeShareType = 'SHARE_TYPE_USER'|'SHARE_TYPE_GROUP'|'SHARE_TYPE_LINK'|'SHARE_TYPE_EMAIL'|'SHARE_TYPE_CONTACT'|'SHARE_TYPE_REMOTE'|'SHARE_TYPE_CIRCLE'|'SHARE_TYPE_GUEST'|'SHARE_TYPE_ROOM'
* @psalm-type AppInfoFieldTypeTypes = 'prelogin'|'filesystem'|'authentication'|'extended_authentication'|'logging'|'dav'|'prevent_group_restriction'|'session'
* @psalm-type AppInfoFieldTypeVcs = 'git'|'mercurial'|'subversion'|'bzr'
*
* The complex types as per info.xsd:
*
* @psalm-type AppInfoFieldTypeAuthor = string|array{
* '@attributes': array{
* 'mail'?: non-empty-string,
* 'homepage'?: non-empty-string
* },
* '@value': non-empty-string,
* }
* @psalm-type AppInfoFieldTypeDocumentation = array{
* 'user'?: non-empty-string,
* 'admin'?: non-empty-string,
* 'developer'?: non-empty-string,
* }
* @psalm-type AppInfoFieldTypeRepository = string|array{
* '@attributes'?: array{
* 'type'?: AppInfoFieldTypeVcs,
* },
* '@value': non-empty-string,
* }
* @psalm-type AppInfoFieldTypeScreenshot = string|array{
* '@attributes'?: array{
* 'small-thumbnail'?: non-empty-string,
* },
* '@value': non-empty-string,
* }
* @psalm-type AppInfoFieldTypeDonation = string|array{
* '@attributes'?: array{
* 'title'?: non-empty-string,
* 'type'?: AppInfoFieldTypeDonationPlatform,
* },
* '@value': non-empty-string,
* }
* @psalm-type AppInfoFieldTypeDependenciesPhp = ''|array{
* '@attributes': array{
* 'min-int-size'?: AppInfoFieldTypeBits,
* 'min-version'?: non-empty-string,
* 'max-version'?: non-empty-string,
* },
* '@value'?: '',
* }
* @psalm-type AppInfoFieldTypeDependenciesDatabase = AppInfoFieldTypeDatabases|array{
* '@attributes': array{
* 'min-version'?: non-empty-string,
* 'max-version'?: non-empty-string,
* },
* '@value': AppInfoFieldTypeDatabases,
* }
* @psalm-type AppInfoFieldTypeDependenciesOwnCloud = array{
* '@attributes': array{
* 'min-version': non-empty-string,
* 'max-version'?: non-empty-string,
* },
* }
* @psalm-type AppInfoFieldTypeDependenciesNextcloud = array{
* '@attributes': array{
* 'min-version': non-empty-string,
* 'max-version': non-empty-string,
* },
* }
* @psalm-type AppInfoFieldTypeDependencies = array{
* 'php'?: AppInfoFieldTypeDependenciesPhp,
* 'database'?: AppInfoFieldTypeDependenciesDatabase|list<AppInfoFieldTypeDependenciesDatabase>,
* 'command'?: non-empty-string|list<non-empty-string>,
* 'lib'?: non-empty-string|list<non-empty-string>,
* 'owncloud'?: AppInfoFieldTypeDependenciesOwnCloud,
* 'nextcloud': AppInfoFieldTypeDependenciesNextcloud,
* 'architecture'?: non-empty-string|list<non-empty-string>,
* 'backend'?: non-empty-string|list<non-empty-string>,
* }
* @psalm-type AppInfoFieldTypeRepairSteps = array{
* 'pre-migration'?: list<class-string>,
* 'post-migration'?: list<class-string>,
* 'live-migration'?: list<class-string>,
* 'install'?: list<class-string>,
* 'uninstall'?: list<class-string>,
* }
* @psalm-type AppInfoFieldTypeSettings = array{
* 'admin'?: list<class-string>,
* 'admin-section'?: list<class-string>,
* 'personal'?: list<class-string>,
* 'personal-section'?: list<class-string>,
* 'admin-delegation'?: list<class-string>,
* 'admin-delegation-section'?: list<class-string>,
* }
* @psalm-type AppInfoFieldTypeActivity = array{
* 'settings'?: list<non-empty-string>,
* 'filters'?: list<non-empty-string>,
* 'providers'?: list<non-empty-string>,
* }
* @psalm-type AppInfoFieldTypeDashboard = array{
* 'widget': list<class-string>,
* }
* @psalm-type AppInfoFieldTypeFullTextSearchProvider = class-string|array{
* '@attributes'?: array{
* 'min-version'?: non-empty-string,
* 'max-version'?: non-empty-string,
* },
* '@value': class-string,
* }
* @psalm-type AppInfoFieldTypeFullTextSearch = array{
* 'platform'?: list<class-string>,
* 'provider'?: list<AppInfoFieldTypeFullTextSearchProvider>,
* }
* @psalm-type AppInfoFieldTypeNavigationEntryValue = array{
* 'id'?: non-empty-string,
* 'name': non-empty-string,
* 'route'?: non-empty-string,
* 'icon'?: non-empty-string,
* 'order'?: numeric,
* 'type'?: AppInfoFieldTypeNavigationType,
* }
* @psalm-type AppInfoFieldTypeNavigationEntry = AppInfoFieldTypeNavigationEntryValue|array{
* '@attributes'?: array{
* 'role'?: AppInfoFieldTypeNavigationRole,
* },
* '@value': AppInfoFieldTypeNavigationEntryValue,
* }
* @psalm-type AppInfoFieldTypeNavigation = array{
* 'navigation': AppInfoFieldTypeNavigationEntry|list<AppInfoFieldTypeNavigationEntry>,
* }
* @psalm-type AppInfoFieldTypeContactMenu = array{
* 'provider': class-string,
* }
* @psalm-type AppInfoFieldTypeCollaborationPlugin = array{
* '@attributes': array{
* 'type': AppInfoFieldTypeCollaborationPluginType,
* 'share-type'?: AppInfoFieldTypeShareType,
* },
* '@value': class-string,
* }
* @psalm-type AppInfoFieldTypeCollaboration = array{
* 'plugins': AppInfoFieldTypeCollaborationPlugin|list<AppInfoFieldTypeCollaborationPlugin>,
* }
* @psalm-type AppInfoFieldTypeOpenMetrics = array{
* 'exporter': class-string|list<class-string>,
* }
* @psalm-type AppInfoFieldTypeSabre = array{
* 'collections'?: class-string|list<class-string>,
* 'plugins'?: class-string|list<class-string>,
* 'address-book-plugins'?: class-string|list<class-string>,
* 'calendar-plugins'?: class-string|list<class-string>,
* }
* @psalm-type AppInfoFieldTypeTrashBackend = array{
* '@attributes': array{
* 'for': class-string,
* },
* '@value': class-string,
* }
* @psalm-type AppInfoFieldTypeTrash = array{
* 'backend': AppInfoFieldTypeTrashBackend|list<AppInfoFieldTypeTrashBackend>,
* }
* @psalm-type AppInfoFieldTypeVersionsBackend = array{
* '@attributes': array{
* 'for': class-string,
* },
* '@value': class-string,
* }
* @psalm-type AppInfoFieldTypeVersions = array{
* 'backend': AppInfoFieldTypeVersionsBackend|list<AppInfoFieldTypeVersionsBackend>,
* }
* @psalm-type AppInfoFieldTypeExternalAppDockerInstall = array{
* 'registry': non-empty-string,
* 'image': non-empty-string,
* 'image-tag': non-empty-string,
* }
* @psalm-type AppInfoFieldTypeExternalAppEnvironmentVariable = array{
* 'name': non-empty-string,
* 'display-name': non-empty-string,
* 'description'?: non-empty-string,
* 'default'?: non-empty-string,
* }
* @psalm-type AppInfoFieldTypeExternalApp = array{
* 'docker-install'?: AppInfoFieldTypeExternalAppDockerInstall,
* 'scopes'?: string|list<string>,
* 'system'?: bool,
* 'environment-variables'?: AppInfoFieldTypeExternalAppEnvironmentVariable|list<AppInfoFieldTypeExternalAppEnvironmentVariable>,
* }
*
* // Only available for shipped apps:
*
* @psalm-type AppInfoFieldTypeShippedAppServices = array<non-empty-string, non-empty-string>
*
* @psalm-type AppInfoSharedDefinition = array{
* 'id': non-empty-string,
* 'version': non-empty-string,
* 'default_enable'?: '',
* 'licence': AppInfoFieldTypeLicense|list<AppInfoFieldTypeLicense>,
* 'author': AppInfoFieldTypeAuthor|list<AppInfoFieldTypeAuthor>,
* 'namespace'?: non-empty-string,
* 'types'?: list<AppInfoFieldTypeTypes>,
* 'documentation'?: AppInfoFieldTypeDocumentation,
* 'category': list<AppInfoFieldTypeCategory>,
* 'website'?: non-empty-string,
* 'discussion'?: non-empty-string,
* 'bugs': non-empty-string,
* 'repository'?: AppInfoFieldTypeRepository,
* 'screenshot'?: AppInfoFieldTypeScreenshot|list<AppInfoFieldTypeScreenshot>,
* 'donation'?: AppInfoFieldTypeDonation|list<AppInfoFieldTypeDonation>,
* 'dependencies': AppInfoFieldTypeDependencies,
* 'background-jobs'?: class-string|list<class-string>,
* 'repair-steps'?: AppInfoFieldTypeRepairSteps,
* 'two-factor-providers'?: list<class-string>,
* 'commands'?: list<class-string>,
* 'settings'?: AppInfoFieldTypeSettings,
* 'activity'?: AppInfoFieldTypeActivity,
* 'dashboard'?: AppInfoFieldTypeDashboard,
* 'fulltextsearch'?: AppInfoFieldTypeFullTextSearch,
* 'navigations'?: AppInfoFieldTypeNavigation,
* 'contactsmenu'?: AppInfoFieldTypeContactMenu,
* 'collaboration'?: AppInfoFieldTypeCollaboration,
* 'openmetrics'?: AppInfoFieldTypeOpenMetrics,
* 'sabre'?: AppInfoFieldTypeSabre,
* 'trash'?: AppInfoFieldTypeTrash,
* 'versions'?: AppInfoFieldTypeVersions,
* 'external-app'?: AppInfoFieldTypeExternalApp,
* 'public'?: AppInfoFieldTypeShippedAppServices,
* 'remote'?: AppInfoFieldTypeShippedAppServices,
* }
*
* // The app info definition with localization applied:
* @psalm-type AppInfoDefinition = AppInfoLocalizedData & AppInfoSharedDefinition
* // The app info definition as it is parsed from XML:
* @psalm-type AppInfoXmlDefinition = AppInfoRawXmlData & AppInfoSharedDefinition
*
* @warning This may change without regular deprecation cycle if the "appinfo.xml" definition changes. Use {@see https://apps.nextcloud.com/schema/apps/info.xsd } as the source of truth.
* @since 34.0.0
*/
final class AppInfoDefinition {
}

View file

@ -16,6 +16,8 @@ use OCP\IUser;
* @warning This interface shouldn't be included with dependency injection in
* classes used for installing Nextcloud.
*
* @psalm-import-type AppInfoDefinition from AppInfoDefinition
* @psalm-import-type AppInfoXmlDefinition from AppInfoDefinition
* @since 8.0.0
*/
interface IAppManager {
@ -28,15 +30,18 @@ interface IAppManager {
* Returns the app information from "appinfo/info.xml" for an app
*
* @param string|null $lang
* @return array|null
* @return AppInfoDefinition|AppInfoXmlDefinition|null
* @psalm-return ($lang is null ? (AppInfoXmlDefinition|null) : (AppInfoDefinition|null))
* @since 14.0.0
* @since 31.0.0 Usage of $path is discontinued and throws an \InvalidArgumentException, use {@see self::getAppInfoByPath} instead.
*/
public function getAppInfo(string $appId, bool $path = false, $lang = null);
public function getAppInfo(string $appId, bool $path = false, $lang = null): ?array;
/**
* Returns the app information from a given path ending with "/appinfo/info.xml"
*
* @return AppInfoDefinition|AppInfoXmlDefinition|null
* @psalm-return ($lang is null ? (AppInfoXmlDefinition|null) : (AppInfoDefinition|null))
* @since 31.0.0
*/
public function getAppInfoByPath(string $path, ?string $lang = null): ?array;

View file

@ -75,6 +75,8 @@
maxOccurs="1" />
<xs:element name="public" type="public" minOccurs="0"
maxOccurs="1" />
<xs:element name="remote" type="remote" minOccurs="0"
maxOccurs="1" />
<xs:element name="trash" type="trash" minOccurs="0"
maxOccurs="1" />
<xs:element name="versions" type="versions" minOccurs="0"
@ -619,7 +621,6 @@
</xs:restriction>
</xs:simpleType>
<xs:complexType name="public">
<xs:sequence>
<xs:element name="webdav" type="path" minOccurs="0" maxOccurs="1"/>
@ -627,6 +628,13 @@
</xs:sequence>
</xs:complexType>
<xs:complexType name="remote">
<xs:sequence>
<xs:element name="webdav" type="path" minOccurs="0" maxOccurs="1"/>
<xs:element name="files" type="path" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<!-- dependencies -->
<xs:complexType name="dependencies">
<xs:sequence>

View file

@ -0,0 +1,162 @@
{
"id": "attributes_once",
"name": {
"@attributes": {
"lang": "en"
},
"@value": "Attributes Once"
},
"summary": {
"@attributes": {
"lang": "en"
},
"@value": "Single occurrence with attributes set on allowed elements."
},
"description": {
"@attributes": {
"lang": "en"
},
"@value": "Fixture that sets attributes where allowed (e.g., lang, type, min-version, for)."
},
"version": "1.2.3",
"licence": "agpl",
"author": {
"@attributes": {
"homepage": "http://example.com",
"mail": "jane@example.com"
},
"@value": "Jane Doe"
},
"types": [
"filesystem"
],
"documentation": {
"user": "https://example.test/attributes-once/user"
},
"category": [
"tools"
],
"website": "https://example.test/attributes-once",
"bugs": "https://example.com/issues",
"repository": {
"@attributes": {
"type": "git"
},
"@value": "https://example.test/attributes-once.git"
},
"screenshot": {
"@attributes": {
"small-thumbnail": "https://example.test/attributes-once-small.png"
},
"@value": "https://example.test/attributes-once.png"
},
"dependencies": {
"php": {
"@attributes": {
"min-version": "8.2"
}
},
"database": {
"@attributes": {
"min-version": "2.0"
},
"@value": "pgsql"
},
"lib": {
"@attributes": {
"min-version": "1.5"
},
"@value": "curl"
},
"owncloud": {
"@attributes": {
"min-version": "1.0",
"max-version": "2.0"
}
},
"nextcloud": {
"@attributes": {
"min-version": "30.0",
"max-version": "31.0"
}
},
"backend": [
"caldav"
]
},
"background-jobs": {
"job": "OCA\\AttributesOnce\\BackgroundJob\\Job"
},
"repair-steps": {
"install": {
"step": "OCA\\AttributesOnce\\RepairStep\\Install"
},
"pre-migration": [],
"post-migration": [],
"live-migration": [],
"uninstall": []
},
"commands": {
"command": "OCA\\AttributesOnce\\Command\\Run"
},
"settings": {
"admin": [
"OCA\\AttributesOnce\\Settings\\Admin"
],
"admin-section": [],
"personal": [],
"personal-section": []
},
"activity": {
"providers": {
"provider": "OCA\\AttributesOnce\\Activity\\Provider"
},
"filters": [],
"settings": []
},
"navigations": {
"navigation": [
{
"@attributes": {
"role": "admin"
},
"name": "Attributes",
"route": "attributes.once.route",
"icon": "attributes-once.svg",
"order": "5"
}
]
},
"collaboration": {
"plugins": {
"@attributes": {
"type": "collaborator-search"
},
"@value": "OCA\\AttributesOnce\\Collaboration\\Plugin"
}
},
"sabre": {
"plugins": {
"plugin": "OCA\\AttributesOnce\\Sabre\\Plugin"
}
},
"trash": {
"backend": {
"@attributes": {
"for": "files"
},
"@value": "OCA\\AttributesOnce\\Trash\\Backend"
}
},
"versions": {
"backend": {
"@attributes": {
"for": "files"
},
"@value": "OCA\\AttributesOnce\\Versions\\Backend"
}
},
"remote": [],
"public": [],
"two-factor-providers": []
}

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

View file

@ -0,0 +1,77 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>attributes_once</id>
<name lang="en">Attributes Once</name>
<summary lang="en">Single occurrence with attributes set on allowed elements.</summary>
<description lang="en">Fixture that sets attributes where allowed (e.g., lang, type, min-version, for).</description>
<version>1.2.3</version>
<licence>agpl</licence>
<author homepage="http://example.com" mail="jane@example.com">Jane Doe</author>
<types>
<filesystem/>
</types>
<documentation>
<user>https://example.test/attributes-once/user</user>
</documentation>
<category>tools</category>
<website>https://example.test/attributes-once</website>
<bugs>https://example.com/issues</bugs>
<repository type="git">https://example.test/attributes-once.git</repository>
<screenshot small-thumbnail="https://example.test/attributes-once-small.png">https://example.test/attributes-once.png</screenshot>
<dependencies>
<php min-version="8.2"/>
<database min-version="2.0">pgsql</database>
<lib min-version="1.5">curl</lib>
<owncloud min-version="1.0" max-version="2.0"/>
<nextcloud min-version="30.0" max-version="31.0"/>
<backend>caldav</backend>
</dependencies>
<background-jobs>
<job>OCA\AttributesOnce\BackgroundJob\Job</job>
</background-jobs>
<repair-steps>
<install>
<step>OCA\AttributesOnce\RepairStep\Install</step>
</install>
</repair-steps>
<commands>
<command>OCA\AttributesOnce\Command\Run</command>
</commands>
<settings>
<admin>OCA\AttributesOnce\Settings\Admin</admin>
</settings>
<activity>
<providers>
<provider>OCA\AttributesOnce\Activity\Provider</provider>
</providers>
</activity>
<navigations>
<navigation role="admin">
<name>Attributes</name>
<route>attributes.once.route</route>
<icon>attributes-once.svg</icon>
<order>5</order>
</navigation>
</navigations>
<collaboration>
<plugins>
<plugin type="collaborator-search">OCA\AttributesOnce\Collaboration\Plugin</plugin>
</plugins>
</collaboration>
<sabre>
<plugins>
<plugin>OCA\AttributesOnce\Sabre\Plugin</plugin>
</plugins>
</sabre>
<trash>
<backend for="files">OCA\AttributesOnce\Trash\Backend</backend>
</trash>
<versions>
<backend for="files">OCA\AttributesOnce\Versions\Backend</backend>
</versions>
</info>

View file

@ -0,0 +1,192 @@
{
"id": "multi_once",
"name": "Multi Once",
"summary": "Every repeatable element is used exactly once.",
"description": "Fixture that exercises the single-item normalization path.",
"version": "1.0.0",
"licence": "agpl",
"author": [
"Jane Doe"
],
"types": [
"filesystem",
"logging"
],
"documentation": {
"user": "https://example.test/multi-once/user",
"admin": "https://example.test/multi-once/admin",
"developer": "https://example.test/multi-once/developer"
},
"category": [
"monitoring"
],
"website": "https://example.test/multi-once",
"discussion": "https://example.test/multi-once/discussion",
"bugs": "https://example.test/multi-once/issues",
"repository": "https://example.test/multi-once.git",
"screenshot": [
"https://example.test/multi-once.png"
],
"donation": "https://example.test/donate",
"dependencies": {
"database": "sqlite",
"command": "awk",
"lib": {
"@attributes": {
"min-version": "1.0"
},
"@value": "curl"
},
"nextcloud": {
"@attributes": {
"min-version": "30.0",
"max-version": "31.0"
}
},
"architecture": "x86_64",
"backend": [
"caldav"
]
},
"background-jobs": {
"job": "OCA\\MultiOnce\\BackgroundJob\\Cleanup"
},
"repair-steps": {
"pre-migration": {
"step": "OCA\\MultiOnce\\RepairStep\\PreMigration"
},
"post-migration": {
"step": "OCA\\MultiOnce\\RepairStep\\PostMigration"
},
"live-migration": {
"step": "OCA\\MultiOnce\\RepairStep\\LiveMigration"
},
"install": {
"step": "OCA\\MultiOnce\\RepairStep\\Install"
},
"uninstall": {
"step": "OCA\\MultiOnce\\RepairStep\\Uninstall"
}
},
"two-factor-providers": {
"provider": "OCA\\MultiOnce\\TwoFactor\\Provider"
},
"commands": {
"command": "OCA\\MultiOnce\\Command\\Migrate"
},
"settings": {
"admin": [
"OCA\\MultiOnce\\Settings\\Admin"
],
"admin-section": [
"OCA\\MultiOnce\\Settings\\AdminSection"
],
"personal": [
"OCA\\MultiOnce\\Settings\\Personal"
],
"personal-section": [
"OCA\\MultiOnce\\Settings\\PersonalSection"
],
"admin-delegation": [
"OCA\\MultiOnce\\Settings\\AdminDelegation"
],
"admin-delegation-section": [
"OCA\\MultiOnce\\Settings\\AdminDelegationSection"
]
},
"activity": {
"settings": {
"setting": "OCA\\MultiOnce\\Activity\\Setting"
},
"filters": {
"filter": "OCA\\MultiOnce\\Activity\\Filter"
},
"providers": {
"provider": "OCA\\MultiOnce\\Activity\\Provider"
}
},
"dashboard": {
"widget": "OCA\\MultiOnce\\Dashboard\\Widget"
},
"fulltextsearch": {
"platform": "OCA\\MultiOnce\\Search\\Platform",
"provider": "OCA\\MultiOnce\\Search\\Provider"
},
"navigations": {
"navigation": [
{
"name": "Multi Once",
"route": "multi.once.route",
"icon": "multi-once.svg",
"order": "1"
}
]
},
"contactsmenu": {
"provider": "OCA\\MultiOnce\\ContactsMenu\\Provider"
},
"collaboration": {
"plugins": {
"@attributes": {
"type": "collaborator-search"
},
"@value": "OCA\\MultiOnce\\Collaboration\\Plugin"
}
},
"openmetrics": {
"exporter": [
"OCA\\MultiOnce\\OpenMetrics\\Exporter"
]
},
"sabre": {
"collections": {
"collection": "OCA\\MultiOnce\\Sabre\\Collection"
},
"plugins": {
"plugin": "OCA\\MultiOnce\\Sabre\\Plugin"
},
"address-book-plugins": {
"plugin": "OCA\\MultiOnce\\Sabre\\AddressBookPlugin"
},
"calendar-plugins": {
"plugin": "OCA\\MultiOnce\\Sabre\\CalendarPlugin"
}
},
"trash": {
"backend": {
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiOnce\\Trash\\Backend"
}
},
"versions": {
"backend": {
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiOnce\\Versions\\Backend"
}
},
"external-app": {
"docker-install": {
"registry": "registry.example.test",
"image": "multi-once",
"image-tag": "1.0.0"
},
"scopes": {
"value": "scope-one"
},
"system": "true",
"environment-variables": {
"variable": {
"name": "MULTI_ONCE_ONE",
"display-name": "Multi Once One",
"description": "First variable",
"default": "one"
}
}
},
"remote": [],
"public": []
}

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

View file

@ -0,0 +1,149 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>multi_once</id>
<name>Multi Once</name>
<summary>Every repeatable element is used exactly once.</summary>
<description>Fixture that exercises the single-item normalization path.</description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>Jane Doe</author>
<types>
<filesystem/>
<logging/>
</types>
<documentation>
<user>https://example.test/multi-once/user</user>
<admin>https://example.test/multi-once/admin</admin>
<developer>https://example.test/multi-once/developer</developer>
</documentation>
<category>monitoring</category>
<website>https://example.test/multi-once</website>
<discussion>https://example.test/multi-once/discussion</discussion>
<bugs>https://example.test/multi-once/issues</bugs>
<repository>https://example.test/multi-once.git</repository>
<screenshot>https://example.test/multi-once.png</screenshot>
<donation>https://example.test/donate</donation>
<dependencies>
<database>sqlite</database>
<command>awk</command>
<lib min-version="1.0">curl</lib>
<nextcloud min-version="30.0" max-version="31.0"/>
<architecture>x86_64</architecture>
<backend>caldav</backend>
</dependencies>
<background-jobs>
<job>OCA\MultiOnce\BackgroundJob\Cleanup</job>
</background-jobs>
<repair-steps>
<pre-migration>
<step>OCA\MultiOnce\RepairStep\PreMigration</step>
</pre-migration>
<post-migration>
<step>OCA\MultiOnce\RepairStep\PostMigration</step>
</post-migration>
<live-migration>
<step>OCA\MultiOnce\RepairStep\LiveMigration</step>
</live-migration>
<install>
<step>OCA\MultiOnce\RepairStep\Install</step>
</install>
<uninstall>
<step>OCA\MultiOnce\RepairStep\Uninstall</step>
</uninstall>
</repair-steps>
<two-factor-providers>
<provider>OCA\MultiOnce\TwoFactor\Provider</provider>
</two-factor-providers>
<commands>
<command>OCA\MultiOnce\Command\Migrate</command>
</commands>
<settings>
<admin>OCA\MultiOnce\Settings\Admin</admin>
<admin-section>OCA\MultiOnce\Settings\AdminSection</admin-section>
<personal>OCA\MultiOnce\Settings\Personal</personal>
<personal-section>OCA\MultiOnce\Settings\PersonalSection</personal-section>
<admin-delegation>OCA\MultiOnce\Settings\AdminDelegation</admin-delegation>
<admin-delegation-section>OCA\MultiOnce\Settings\AdminDelegationSection</admin-delegation-section>
</settings>
<activity>
<settings>
<setting>OCA\MultiOnce\Activity\Setting</setting>
</settings>
<filters>
<filter>OCA\MultiOnce\Activity\Filter</filter>
</filters>
<providers>
<provider>OCA\MultiOnce\Activity\Provider</provider>
</providers>
</activity>
<dashboard>
<widget>OCA\MultiOnce\Dashboard\Widget</widget>
</dashboard>
<fulltextsearch>
<platform>OCA\MultiOnce\Search\Platform</platform>
<provider>OCA\MultiOnce\Search\Provider</provider>
</fulltextsearch>
<navigations>
<navigation>
<name>Multi Once</name>
<route>multi.once.route</route>
<icon>multi-once.svg</icon>
<order>1</order>
</navigation>
</navigations>
<contactsmenu>
<provider>OCA\MultiOnce\ContactsMenu\Provider</provider>
</contactsmenu>
<collaboration>
<plugins>
<plugin type="collaborator-search">OCA\MultiOnce\Collaboration\Plugin</plugin>
</plugins>
</collaboration>
<openmetrics>
<exporter>OCA\MultiOnce\OpenMetrics\Exporter</exporter>
</openmetrics>
<sabre>
<collections>
<collection>OCA\MultiOnce\Sabre\Collection</collection>
</collections>
<plugins>
<plugin>OCA\MultiOnce\Sabre\Plugin</plugin>
</plugins>
<address-book-plugins>
<plugin>OCA\MultiOnce\Sabre\AddressBookPlugin</plugin>
</address-book-plugins>
<calendar-plugins>
<plugin>OCA\MultiOnce\Sabre\CalendarPlugin</plugin>
</calendar-plugins>
</sabre>
<trash>
<backend for="files">OCA\MultiOnce\Trash\Backend</backend>
</trash>
<versions>
<backend for="files">OCA\MultiOnce\Versions\Backend</backend>
</versions>
<external-app>
<docker-install>
<registry>registry.example.test</registry>
<image>multi-once</image>
<image-tag>1.0.0</image-tag>
</docker-install>
<scopes>
<value>scope-one</value>
</scopes>
<system>true</system>
<environment-variables>
<variable>
<name>MULTI_ONCE_ONE</name>
<display-name>Multi Once One</display-name>
<description>First variable</description>
<default>one</default>
</variable>
</environment-variables>
</external-app>
</info>

View file

@ -0,0 +1,325 @@
{
"id": "multi_twice",
"name": "Multi Twice",
"summary": "Every repeatable element is used exactly twice.",
"description": "Fixture that exercises the list normalization path.",
"version": "1.0.0",
"licence": [
"agpl",
"mit"
],
"author": [
"Jane Doe",
"John Doe"
],
"types": [
"filesystem",
"logging"
],
"documentation": {
"user": "https://example.test/multi-twice/user",
"admin": "https://example.test/multi-twice/admin",
"developer": "https://example.test/multi-twice/developer"
},
"category": [
"monitoring",
"social"
],
"website": "https://example.test/multi-twice",
"discussion": "https://example.test/multi-twice/discussion",
"bugs": "https://example.test/multi-twice/issues",
"repository": {
"@attributes": {
"type": "git"
},
"@value": "https://example.test/multi-twice.git"
},
"screenshot": [
"https://example.test/multi-twice-1.png",
"https://example.test/multi-twice-2.png"
],
"donation": [
"https://example.test/donate/1",
"https://example.test/donate/2"
],
"dependencies": {
"php": {
"@attributes": {
"min-version": "8.2"
}
},
"database": [
{
"@attributes": {
"min-version": "1.0"
},
"@value": "sqlite"
},
{
"@attributes": {
"min-version": "1.0"
},
"@value": "mysql"
}
],
"command": [
"awk",
"grep"
],
"lib": [
{
"@attributes": {
"min-version": "1.0"
},
"@value": "curl"
},
{
"@attributes": {
"min-version": "1.0"
},
"@value": "intl"
}
],
"owncloud": {
"@attributes": {
"min-version": "1.0",
"max-version": "2.0"
}
},
"nextcloud": {
"@attributes": {
"min-version": "30.0",
"max-version": "31.0"
}
},
"architecture": [
"x86_64",
"aarch64"
],
"backend": [
"caldav",
"caldav"
]
},
"background-jobs": [
"OCA\\MultiTwice\\BackgroundJob\\CleanupOne",
"OCA\\MultiTwice\\BackgroundJob\\CleanupTwo"
],
"repair-steps": {
"pre-migration": [
"OCA\\MultiTwice\\RepairStep\\PreMigrationOne",
"OCA\\MultiTwice\\RepairStep\\PreMigrationTwo"
],
"post-migration": [
"OCA\\MultiTwice\\RepairStep\\PostMigrationOne",
"OCA\\MultiTwice\\RepairStep\\PostMigrationTwo"
],
"live-migration": [
"OCA\\MultiTwice\\RepairStep\\LiveMigrationOne",
"OCA\\MultiTwice\\RepairStep\\LiveMigrationTwo"
],
"install": [
"OCA\\MultiTwice\\RepairStep\\InstallOne",
"OCA\\MultiTwice\\RepairStep\\InstallTwo"
],
"uninstall": [
"OCA\\MultiTwice\\RepairStep\\UninstallOne",
"OCA\\MultiTwice\\RepairStep\\UninstallTwo"
]
},
"two-factor-providers": [
"OCA\\MultiTwice\\TwoFactor\\ProviderOne",
"OCA\\MultiTwice\\TwoFactor\\ProviderTwo"
],
"commands": [
"OCA\\MultiTwice\\Command\\MigrateOne",
"OCA\\MultiTwice\\Command\\MigrateTwo"
],
"settings": {
"admin": [
"OCA\\MultiTwice\\Settings\\AdminOne",
"OCA\\MultiTwice\\Settings\\AdminTwo"
],
"admin-section": [
"OCA\\MultiTwice\\Settings\\AdminSectionOne",
"OCA\\MultiTwice\\Settings\\AdminSectionTwo"
],
"personal": [
"OCA\\MultiTwice\\Settings\\PersonalOne",
"OCA\\MultiTwice\\Settings\\PersonalTwo"
],
"personal-section": [
"OCA\\MultiTwice\\Settings\\PersonalSectionOne",
"OCA\\MultiTwice\\Settings\\PersonalSectionTwo"
],
"admin-delegation": [
"OCA\\MultiTwice\\Settings\\AdminDelegationOne",
"OCA\\MultiTwice\\Settings\\AdminDelegationTwo"
],
"admin-delegation-section": [
"OCA\\MultiTwice\\Settings\\AdminDelegationSectionOne",
"OCA\\MultiTwice\\Settings\\AdminDelegationSectionTwo"
]
},
"activity": {
"settings": [
"OCA\\MultiTwice\\Activity\\SettingOne",
"OCA\\MultiTwice\\Activity\\SettingTwo"
],
"filters": [
"OCA\\MultiTwice\\Activity\\FilterOne",
"OCA\\MultiTwice\\Activity\\FilterTwo"
],
"providers": [
"OCA\\MultiTwice\\Activity\\ProviderOne",
"OCA\\MultiTwice\\Activity\\ProviderTwo"
]
},
"dashboard": {
"widget": [
"OCA\\MultiTwice\\Dashboard\\WidgetOne",
"OCA\\MultiTwice\\Dashboard\\WidgetTwo"
]
},
"fulltextsearch": {
"platform": [
"OCA\\MultiTwice\\Search\\PlatformOne",
"OCA\\MultiTwice\\Search\\PlatformTwo"
],
"provider": [
"OCA\\MultiTwice\\Search\\ProviderOne",
"OCA\\MultiTwice\\Search\\ProviderTwo"
]
},
"navigations": {
"navigation": [
{
"name": "Multi Twice One",
"route": "multi.twice.one",
"icon": "multi-twice-1.svg",
"order": "1"
},
{
"name": "Multi Twice Two",
"route": "multi.twice.two",
"icon": "multi-twice-2.svg",
"order": "2"
}
]
},
"contactsmenu": {
"provider": "OCA\\MultiTwice\\ContactsMenu\\Provider"
},
"collaboration": {
"plugins": [
{
"@attributes": {
"type": "collaborator-search"
},
"@value": "OCA\\MultiTwice\\Collaboration\\PluginOne"
},
{
"@attributes": {
"type": "autocomplete-sort"
},
"@value": "OCA\\MultiTwice\\Collaboration\\PluginTwo"
}
]
},
"openmetrics": {
"exporter": [
"OCA\\MultiTwice\\OpenMetrics\\ExporterOne",
"OCA\\MultiTwice\\OpenMetrics\\ExporterTwo"
]
},
"sabre": {
"collections": {
"collection": [
"OCA\\MultiTwice\\Sabre\\CollectionOne",
"OCA\\MultiTwice\\Sabre\\CollectionTwo"
]
},
"plugins": {
"plugin": [
"OCA\\MultiTwice\\Sabre\\PluginOne",
"OCA\\MultiTwice\\Sabre\\PluginTwo"
]
},
"address-book-plugins": {
"plugin": [
"OCA\\MultiTwice\\Sabre\\AddressBookPluginOne",
"OCA\\MultiTwice\\Sabre\\AddressBookPluginTwo"
]
},
"calendar-plugins": {
"plugin": [
"OCA\\MultiTwice\\Sabre\\CalendarPluginOne",
"OCA\\MultiTwice\\Sabre\\CalendarPluginTwo"
]
}
},
"trash": {
"backend": [
{
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiTwice\\Trash\\BackendOne"
},
{
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiTwice\\Trash\\BackendTwo"
}
]
},
"versions": {
"backend": [
{
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiTwice\\Versions\\BackendOne"
},
{
"@attributes": {
"for": "files"
},
"@value": "OCA\\MultiTwice\\Versions\\BackendTwo"
}
]
},
"external-app": {
"docker-install": {
"registry": "registry.example.test",
"image": "multi-twice",
"image-tag": "2.0.0"
},
"scopes": {
"value": [
"scope-one",
"scope-two"
]
},
"system": "true",
"environment-variables": {
"variable": [
{
"name": "MULTI_TWICE_ONE",
"display-name": "Multi Twice One",
"description": "First variable",
"default": "one"
},
{
"name": "MULTI_TWICE_TWO",
"display-name": "Multi Twice Two",
"description": "Second variable",
"default": "two"
}
]
}
},
"remote": [],
"public": []
}

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

View file

@ -0,0 +1,202 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>multi_twice</id>
<name>Multi Twice</name>
<summary>Every repeatable element is used exactly twice.</summary>
<description>Fixture that exercises the list normalization path.</description>
<version>1.0.0</version>
<licence>agpl</licence>
<licence>mit</licence>
<author>Jane Doe</author>
<author>John Doe</author>
<types>
<filesystem/>
<logging/>
</types>
<documentation>
<user>https://example.test/multi-twice/user</user>
<admin>https://example.test/multi-twice/admin</admin>
<developer>https://example.test/multi-twice/developer</developer>
</documentation>
<category>monitoring</category>
<category>social</category>
<website>https://example.test/multi-twice</website>
<discussion>https://example.test/multi-twice/discussion</discussion>
<bugs>https://example.test/multi-twice/issues</bugs>
<repository type="git">https://example.test/multi-twice.git</repository>
<screenshot>https://example.test/multi-twice-1.png</screenshot>
<screenshot>https://example.test/multi-twice-2.png</screenshot>
<donation>https://example.test/donate/1</donation>
<donation>https://example.test/donate/2</donation>
<dependencies>
<php min-version="8.2"/>
<database min-version="1.0">sqlite</database>
<database min-version="1.0">mysql</database>
<command>awk</command>
<command>grep</command>
<lib min-version="1.0">curl</lib>
<lib min-version="1.0">intl</lib>
<owncloud min-version="1.0" max-version="2.0"/>
<nextcloud min-version="30.0" max-version="31.0"/>
<architecture>x86_64</architecture>
<architecture>aarch64</architecture>
<backend>caldav</backend>
<backend>caldav</backend>
</dependencies>
<background-jobs>
<job>OCA\MultiTwice\BackgroundJob\CleanupOne</job>
<job>OCA\MultiTwice\BackgroundJob\CleanupTwo</job>
</background-jobs>
<repair-steps>
<pre-migration>
<step>OCA\MultiTwice\RepairStep\PreMigrationOne</step>
<step>OCA\MultiTwice\RepairStep\PreMigrationTwo</step>
</pre-migration>
<post-migration>
<step>OCA\MultiTwice\RepairStep\PostMigrationOne</step>
<step>OCA\MultiTwice\RepairStep\PostMigrationTwo</step>
</post-migration>
<live-migration>
<step>OCA\MultiTwice\RepairStep\LiveMigrationOne</step>
<step>OCA\MultiTwice\RepairStep\LiveMigrationTwo</step>
</live-migration>
<install>
<step>OCA\MultiTwice\RepairStep\InstallOne</step>
<step>OCA\MultiTwice\RepairStep\InstallTwo</step>
</install>
<uninstall>
<step>OCA\MultiTwice\RepairStep\UninstallOne</step>
<step>OCA\MultiTwice\RepairStep\UninstallTwo</step>
</uninstall>
</repair-steps>
<two-factor-providers>
<provider>OCA\MultiTwice\TwoFactor\ProviderOne</provider>
<provider>OCA\MultiTwice\TwoFactor\ProviderTwo</provider>
</two-factor-providers>
<commands>
<command>OCA\MultiTwice\Command\MigrateOne</command>
<command>OCA\MultiTwice\Command\MigrateTwo</command>
</commands>
<settings>
<admin>OCA\MultiTwice\Settings\AdminOne</admin>
<admin>OCA\MultiTwice\Settings\AdminTwo</admin>
<admin-section>OCA\MultiTwice\Settings\AdminSectionOne</admin-section>
<admin-section>OCA\MultiTwice\Settings\AdminSectionTwo</admin-section>
<personal>OCA\MultiTwice\Settings\PersonalOne</personal>
<personal>OCA\MultiTwice\Settings\PersonalTwo</personal>
<personal-section>OCA\MultiTwice\Settings\PersonalSectionOne</personal-section>
<personal-section>OCA\MultiTwice\Settings\PersonalSectionTwo</personal-section>
<admin-delegation>OCA\MultiTwice\Settings\AdminDelegationOne</admin-delegation>
<admin-delegation>OCA\MultiTwice\Settings\AdminDelegationTwo</admin-delegation>
<admin-delegation-section>OCA\MultiTwice\Settings\AdminDelegationSectionOne</admin-delegation-section>
<admin-delegation-section>OCA\MultiTwice\Settings\AdminDelegationSectionTwo</admin-delegation-section>
</settings>
<activity>
<settings>
<setting>OCA\MultiTwice\Activity\SettingOne</setting>
<setting>OCA\MultiTwice\Activity\SettingTwo</setting>
</settings>
<filters>
<filter>OCA\MultiTwice\Activity\FilterOne</filter>
<filter>OCA\MultiTwice\Activity\FilterTwo</filter>
</filters>
<providers>
<provider>OCA\MultiTwice\Activity\ProviderOne</provider>
<provider>OCA\MultiTwice\Activity\ProviderTwo</provider>
</providers>
</activity>
<dashboard>
<widget>OCA\MultiTwice\Dashboard\WidgetOne</widget>
<widget>OCA\MultiTwice\Dashboard\WidgetTwo</widget>
</dashboard>
<fulltextsearch>
<platform>OCA\MultiTwice\Search\PlatformOne</platform>
<platform>OCA\MultiTwice\Search\PlatformTwo</platform>
<provider>OCA\MultiTwice\Search\ProviderOne</provider>
<provider>OCA\MultiTwice\Search\ProviderTwo</provider>
</fulltextsearch>
<navigations>
<navigation>
<name>Multi Twice One</name>
<route>multi.twice.one</route>
<icon>multi-twice-1.svg</icon>
<order>1</order>
</navigation>
<navigation>
<name>Multi Twice Two</name>
<route>multi.twice.two</route>
<icon>multi-twice-2.svg</icon>
<order>2</order>
</navigation>
</navigations>
<contactsmenu>
<provider>OCA\MultiTwice\ContactsMenu\Provider</provider>
</contactsmenu>
<collaboration>
<plugins>
<plugin type="collaborator-search">OCA\MultiTwice\Collaboration\PluginOne</plugin>
<plugin type="autocomplete-sort">OCA\MultiTwice\Collaboration\PluginTwo</plugin>
</plugins>
</collaboration>
<openmetrics>
<exporter>OCA\MultiTwice\OpenMetrics\ExporterOne</exporter>
<exporter>OCA\MultiTwice\OpenMetrics\ExporterTwo</exporter>
</openmetrics>
<sabre>
<collections>
<collection>OCA\MultiTwice\Sabre\CollectionOne</collection>
<collection>OCA\MultiTwice\Sabre\CollectionTwo</collection>
</collections>
<plugins>
<plugin>OCA\MultiTwice\Sabre\PluginOne</plugin>
<plugin>OCA\MultiTwice\Sabre\PluginTwo</plugin>
</plugins>
<address-book-plugins>
<plugin>OCA\MultiTwice\Sabre\AddressBookPluginOne</plugin>
<plugin>OCA\MultiTwice\Sabre\AddressBookPluginTwo</plugin>
</address-book-plugins>
<calendar-plugins>
<plugin>OCA\MultiTwice\Sabre\CalendarPluginOne</plugin>
<plugin>OCA\MultiTwice\Sabre\CalendarPluginTwo</plugin>
</calendar-plugins>
</sabre>
<trash>
<backend for="files">OCA\MultiTwice\Trash\BackendOne</backend>
<backend for="files">OCA\MultiTwice\Trash\BackendTwo</backend>
</trash>
<versions>
<backend for="files">OCA\MultiTwice\Versions\BackendOne</backend>
<backend for="files">OCA\MultiTwice\Versions\BackendTwo</backend>
</versions>
<external-app>
<docker-install>
<registry>registry.example.test</registry>
<image>multi-twice</image>
<image-tag>2.0.0</image-tag>
</docker-install>
<scopes>
<value>scope-one</value>
<value>scope-two</value>
</scopes>
<system>true</system>
<environment-variables>
<variable>
<name>MULTI_TWICE_ONE</name>
<display-name>Multi Twice One</display-name>
<description>First variable</description>
<default>one</default>
</variable>
<variable>
<name>MULTI_TWICE_TWO</name>
<display-name>Multi Twice Two</display-name>
<description>Second variable</description>
<default>two</default>
</variable>
</environment-variables>
</external-app>
</info>

View file

@ -1,12 +0,0 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info>
<id>files_encryption</id>
<name>Server-side Encryption</name>
<description lang="en">English</description>
<description lang="de">German</description>
<licence>AGPL</licence>
</info>

View file

@ -1,11 +0,0 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info>
<id>files_encryption</id>
<name>Server-side Encryption</name>
<description lang="en">English</description>
<licence>AGPL</licence>
</info>

View file

@ -1,5 +1,4 @@
{
"info": [],
"remote": [],
"public": [],
"id": "files_encryption",

View file

@ -72,7 +72,6 @@
}
]
},
"info": [],
"remote": [],
"public": [],
"repair-steps": {

View file

@ -78,7 +78,6 @@
}
]
},
"info": [],
"remote": [],
"public": [],
"repair-steps": {

View file

@ -22,7 +22,6 @@
"category": [
"monitoring"
],
"info": [],
"background-jobs": [],
"activity": {
"filters": [],

View file

@ -22,7 +22,7 @@ class InfoParserTest extends TestCase {
self::$cache = new CappedMemoryCache();
}
public function parserTest($expectedJson, $xmlFile, $cache = null) {
protected function parserTest($expectedJson, $xmlFile, $cache = null) {
$parser = new InfoParser($cache);
$expectedData = null;
@ -44,13 +44,22 @@ class InfoParserTest extends TestCase {
$this->parserTest($expectedJson, $xmlFile, self::$cache);
}
#[\PHPUnit\Framework\Attributes\DataProvider('appDataProvider')]
public function testApplyL10N(array $data, array $expected, string $language): void {
$parser = new InfoParser();
$this->assertEqualsCanonicalizing($expected, $parser->applyL10N($data, $language));
}
public static function providesInfoXml(): array {
return [
['expected-info.json', 'valid-info.xml'],
[null, 'invalid-info.xml'],
['navigation-one-item.json', 'navigation-one-item.xml'],
['navigation-two-items.json', 'navigation-two-items.xml'],
['various-single-item.json', 'various-single-item.xml'],
'Only one value in each list' => ['appinfo-multi-once.json', 'appinfo-multi-once.xml'],
'Only one value in each list with attributes' => ['appinfo-attributes-once.json', 'appinfo-attributes-once.xml'],
'Multiple values in each list' => ['appinfo-multi-twice.json', 'appinfo-multi-twice.xml'],
'Valid info' => ['expected-info.json', 'valid-info.xml'],
'Invalid info' => [null, 'invalid-info.xml'],
'Navigation one item' => ['navigation-one-item.json', 'navigation-one-item.xml'],
'Navigation two items' => ['navigation-two-items.json', 'navigation-two-items.xml'],
'Various single item' => ['various-single-item.json', 'various-single-item.xml'],
];
}
@ -58,52 +67,63 @@ class InfoParserTest extends TestCase {
* Providers for the app data values
*/
public static function appDataProvider(): array {
$FULL_TRANSLATED = [
'name' => [
['@attributes' => ['lang' => 'en'], '@value' => 'App'],
['@attributes' => ['lang' => 'fr'], '@value' => 'Application']
],
'summary' => [
'Summary',
['@attributes' => ['lang' => 'fr'], '@value' => 'Résumé']
],
'description' => [
['@attributes' => ['lang' => 'en'], '@value' => 'Description'],
['@attributes' => ['lang' => 'fr'], '@value' => 'Description (fr)']
]
];
return [
// test trimming
[
['description' => " \t This is a multiline \n test with \n \t \n \n some new lines "],
['description' => "This is a multiline \n test with \n \t \n \n some new lines"],
['description' => "This is a multiline \n test with \n \t \n \n some new lines", 'summary' => '', 'name' => ''],
'en'
],
[
['description' => " \t This is a multiline \n test with \n \t some new lines "],
['description' => "This is a multiline \n test with \n \t some new lines"],
['description' => "This is a multiline \n test with \n \t some new lines", 'summary' => '', 'name' => ''],
'en'
],
[
['description' => hex2bin('5065726d657420646520732761757468656e7469666965722064616e732070697769676f20646972656374656d656e74206176656320736573206964656e74696669616e7473206f776e636c6f75642073616e73206c65732072657461706572206574206d657420c3a0206a6f757273206365757820636920656e20636173206465206368616e67656d656e74206465206d6f742064652070617373652e0d0a0d')],
['description' => "Permet de s'authentifier dans piwigo directement avec ses identifiants owncloud sans les retaper et met à jours ceux ci en cas de changement de mot de passe."],
['description' => "Permet de s'authentifier dans piwigo directement avec ses identifiants owncloud sans les retaper et met à jours ceux ci en cas de changement de mot de passe.", 'summary' => '', 'name' => ''],
'fr'
],
// test proper translation handling
// just strings:
[
['not-a-description' => " \t This is a multiline \n test with \n \t some new lines "],
[
'not-a-description' => " \t This is a multiline \n test with \n \t some new lines ",
'description' => '',
],
['name' => 'App', 'summary' => 'Summary', 'description' => 'Description'],
['name' => 'App', 'summary' => 'Summary', 'description' => 'Description'],
'en'
],
// translated and requesting English:
[
['description' => [100, 'bla']],
['description' => ''],
$FULL_TRANSLATED,
['name' => 'App', 'summary' => 'Summary', 'description' => 'Description'],
'en'
],
// translated and requesting translation:
[
$FULL_TRANSLATED,
['name' => 'Application', 'summary' => 'Résumé', 'description' => 'Description (fr)'],
'fr'
],
// translated but requesting non existing translation, should fallback to English:
[
$FULL_TRANSLATED,
['name' => 'App', 'summary' => 'Summary', 'description' => 'Description'],
'de'
]
];
}
/**
* Test app info parser
*/
#[\PHPUnit\Framework\Attributes\DataProvider('appDataProvider')]
public function testApplyL10NNoLanguage(array $data, array $expected): void {
$parser = new InfoParser();
$this->assertSame($expected, $parser->applyL10N($data));
}
public function testApplyL10N(): void {
$parser = new InfoParser();
$data = $parser->parse(\OC::$SERVERROOT . '/tests/data/app/description-multi-lang.xml');
$this->assertEquals('English', $parser->applyL10N($data, 'en')['description']);
$this->assertEquals('German', $parser->applyL10N($data, 'de')['description']);
}
public function testApplyL10NSingleLanguage(): void {
$parser = new InfoParser();
$data = $parser->parse(\OC::$SERVERROOT . '/tests/data/app/description-single-lang.xml');
$this->assertEquals('English', $parser->applyL10N($data, 'en')['description']);
}
}