diff --git a/apps/profile/appinfo/info.xml b/apps/profile/appinfo/info.xml
index 1310eb1f7f05a..b0faec62e454f 100644
--- a/apps/profile/appinfo/info.xml
+++ b/apps/profile/appinfo/info.xml
@@ -8,9 +8,9 @@
profile
Profile
This application provides the profile
- Provides a customisable user profile interface.
+ Provides a customizable user profile interface.
2.0.0-dev.0
- agpl
+ AGPL-3.0-or-later
Nextcloud GmbH
Profile
social
diff --git a/apps/profile/composer/composer/autoload_classmap.php b/apps/profile/composer/composer/autoload_classmap.php
index 8e2b1a20ee04e..623dce51d5393 100644
--- a/apps/profile/composer/composer/autoload_classmap.php
+++ b/apps/profile/composer/composer/autoload_classmap.php
@@ -9,6 +9,7 @@
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\Profile\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\Profile\\Controller\\ProfilePageController' => $baseDir . '/../lib/Controller/ProfilePageController.php',
+ 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php',
'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => $baseDir . '/../lib/Listener/ProfilePickerReferenceListener.php',
'OCA\\Profile\\Reference\\ProfilePickerReferenceProvider' => $baseDir . '/../lib/Reference/ProfilePickerReferenceProvider.php',
);
diff --git a/apps/profile/composer/composer/autoload_static.php b/apps/profile/composer/composer/autoload_static.php
index a12287d97196e..41efca598c418 100644
--- a/apps/profile/composer/composer/autoload_static.php
+++ b/apps/profile/composer/composer/autoload_static.php
@@ -24,6 +24,7 @@ class ComposerStaticInitProfile
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\Profile\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\Profile\\Controller\\ProfilePageController' => __DIR__ . '/..' . '/../lib/Controller/ProfilePageController.php',
+ 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php',
'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => __DIR__ . '/..' . '/../lib/Listener/ProfilePickerReferenceListener.php',
'OCA\\Profile\\Reference\\ProfilePickerReferenceProvider' => __DIR__ . '/..' . '/../lib/Reference/ProfilePickerReferenceProvider.php',
);
diff --git a/apps/profile/lib/AppInfo/Application.php b/apps/profile/lib/AppInfo/Application.php
index 35569c8c6ae31..52d5939cdb6eb 100644
--- a/apps/profile/lib/AppInfo/Application.php
+++ b/apps/profile/lib/AppInfo/Application.php
@@ -9,6 +9,7 @@
namespace OCA\Profile\AppInfo;
+use OCA\Profile\Listener\LoadAdditionalEntriesListener;
use OCA\Profile\Listener\ProfilePickerReferenceListener;
use OCA\Profile\Reference\ProfilePickerReferenceProvider;
use OCP\AppFramework\App;
@@ -16,11 +17,7 @@
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Collaboration\Reference\RenderReferenceEvent;
-use OCP\INavigationManager;
-use OCP\IURLGenerator;
-use OCP\IUserSession;
-use OCP\L10N\IFactory;
-use OCP\Server;
+use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'profile';
@@ -33,37 +30,10 @@ public function __construct(array $urlParams = []) {
public function register(IRegistrationContext $context): void {
$context->registerReferenceProvider(ProfilePickerReferenceProvider::class);
$context->registerEventListener(RenderReferenceEvent::class, ProfilePickerReferenceListener::class);
+ $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class);
}
#[\Override]
public function boot(IBootContext $context): void {
- $context->injectFn($this->registerNavigationEntry(...));
- }
-
- /**
- * Registers the navigation entry for the profile app in the user settings.
- * Needed as the href is dynamic and thus we cannot use the appinfo/info.xml
- */
- public function registerNavigationEntry(
- INavigationManager $navigationManager,
- IUserSession $userSession,
- IURLGenerator $urlGenerator,
- ): void {
- if (!$userSession->isLoggedIn()) {
- return;
- }
-
- $l = Server::get(IFactory::class)->get('profile');
- // Profile
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'profile',
- 'order' => 1,
- 'href' => $urlGenerator->linkToRoute(
- 'profile.ProfilePage.index',
- ['targetUserId' => $userSession->getUser()->getUID()],
- ),
- 'name' => $l->t('View profile'),
- ]);
}
}
diff --git a/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php b/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php
new file mode 100644
index 0000000000000..6107a4b74358c
--- /dev/null
+++ b/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php
@@ -0,0 +1,52 @@
+ */
+class LoadAdditionalEntriesListener implements IEventListener {
+
+ public function __construct(
+ private readonly IL10N $l10n,
+ private readonly INavigationManager $navigationManager,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly IUserSession $userSession,
+ ) {
+ }
+
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadAdditionalEntriesEvent)) {
+ return;
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return;
+ }
+
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'profile',
+ 'order' => 1,
+ 'href' => $this->urlGenerator->linkToRoute(
+ 'profile.ProfilePage.index',
+ ['targetUserId' => $user->getUID()],
+ ),
+ 'name' => $this->l10n->t('View profile'),
+ ]);
+ }
+}
diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php
index a526b46873f77..618e4cff3c619 100644
--- a/apps/settings/composer/composer/autoload_classmap.php
+++ b/apps/settings/composer/composer/autoload_classmap.php
@@ -41,6 +41,7 @@
'OCA\\Settings\\Hooks' => $baseDir . '/../lib/Hooks.php',
'OCA\\Settings\\Listener\\AppPasswordCreatedActivityListener' => $baseDir . '/../lib/Listener/AppPasswordCreatedActivityListener.php',
'OCA\\Settings\\Listener\\GroupRemovedListener' => $baseDir . '/../lib/Listener/GroupRemovedListener.php',
+ 'OCA\\Settings\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php',
'OCA\\Settings\\Listener\\MailProviderListener' => $baseDir . '/../lib/Listener/MailProviderListener.php',
'OCA\\Settings\\Listener\\UserAddedToGroupActivityListener' => $baseDir . '/../lib/Listener/UserAddedToGroupActivityListener.php',
'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => $baseDir . '/../lib/Listener/UserRemovedFromGroupActivityListener.php',
diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php
index 2cba33bd7dcb4..9fdba0484599c 100644
--- a/apps/settings/composer/composer/autoload_static.php
+++ b/apps/settings/composer/composer/autoload_static.php
@@ -56,6 +56,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
'OCA\\Settings\\Listener\\AppPasswordCreatedActivityListener' => __DIR__ . '/..' . '/../lib/Listener/AppPasswordCreatedActivityListener.php',
'OCA\\Settings\\Listener\\GroupRemovedListener' => __DIR__ . '/..' . '/../lib/Listener/GroupRemovedListener.php',
+ 'OCA\\Settings\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php',
'OCA\\Settings\\Listener\\MailProviderListener' => __DIR__ . '/..' . '/../lib/Listener/MailProviderListener.php',
'OCA\\Settings\\Listener\\UserAddedToGroupActivityListener' => __DIR__ . '/..' . '/../lib/Listener/UserAddedToGroupActivityListener.php',
'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => __DIR__ . '/..' . '/../lib/Listener/UserRemovedFromGroupActivityListener.php',
diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php
index ff1f0d222f0e8..5faf7b5110b0c 100644
--- a/apps/settings/lib/AppInfo/Application.php
+++ b/apps/settings/lib/AppInfo/Application.php
@@ -17,6 +17,7 @@
use OCA\Settings\Hooks;
use OCA\Settings\Listener\AppPasswordCreatedActivityListener;
use OCA\Settings\Listener\GroupRemovedListener;
+use OCA\Settings\Listener\LoadAdditionalEntriesListener;
use OCA\Settings\Listener\MailProviderListener;
use OCA\Settings\Listener\UserAddedToGroupActivityListener;
use OCA\Settings\Listener\UserRemovedFromGroupActivityListener;
@@ -92,14 +93,11 @@
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserAddedEvent;
use OCP\Group\Events\UserRemovedEvent;
-use OCP\Group\ISubAdmin;
use OCP\IConfig;
-use OCP\IGroupManager;
-use OCP\INavigationManager;
use OCP\IURLGenerator;
-use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
+use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Server;
@@ -135,6 +133,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(GroupDeletedEvent::class, GroupRemovedListener::class);
$context->registerEventListener(PasswordUpdatedEvent::class, Hooks::class);
$context->registerEventListener(UserChangedEvent::class, Hooks::class);
+ $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class);
// Register Mail Provider listeners
$context->registerEventListener(DeclarativeSettingsGetValueEvent::class, MailProviderListener::class);
@@ -233,93 +232,5 @@ public function register(IRegistrationContext $context): void {
#[\Override]
public function boot(IBootContext $context): void {
- $context->injectFn($this->registerNavigationEntries(...));
- }
-
- /**
- * Registers the navigation entries for the user settings.
- * Needed as some entries are dynamic and thus we cannot use the appinfo/info.xml
- *
- * Registers the following entries:
- * - Appearance and accessibility
- * - Personal settings (named "Settings" for non-admins)
- * - Accounts (only for subadmins)
- * - Help & privacy (conditionally enabled based on config)
- */
- public function registerNavigationEntries(
- INavigationManager $navigationManager,
- IURLGenerator $urlGenerator,
- IUserSession $userSession,
- IConfig $config,
- ): void {
- if ($userSession->getUser() === null) {
- return;
- }
-
- $l = Server::get(IFactory::class)
- ->get('settings');
- $groupManager = Server::get(IGroupManager::class);
- $subAdmin = Server::get(ISubAdmin::class);
- $isAdmin = $groupManager->isAdmin($userSession->getUser()->getUID());
- $isSubAdmin = $subAdmin->isSubAdmin($userSession->getUser());
-
- // Accessibility settings - the URL is dynamic (route parameters) which is currently not supported by appinfo.xml
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'accessibility_settings',
- 'order' => 2,
- 'href' => $urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'theming']),
- 'name' => $l->t('Appearance and accessibility'),
- 'icon' => $urlGenerator->imagePath('theming', 'accessibility-dark.svg'),
- ]);
-
- // Personal settings - this entry is dynamic so we cannot use appinfo
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'settings_personal',
- 'order' => 3,
- 'href' => $urlGenerator->linkToRoute('settings.PersonalSettings.index'),
- 'name' => $isAdmin
- ? $l->t('Personal settings')
- : $l->t('Settings'),
- 'icon' => $isAdmin
- ? $urlGenerator->imagePath('settings', 'personal.svg')
- : $urlGenerator->imagePath('settings', 'admin.svg'),
- ]);
-
- if ($isAdmin) {
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'settings_administration',
- 'order' => 4,
- 'href' => $urlGenerator->linkToRoute('settings.adminSettings.index'),
- 'name' => $l->t('Administration settings'),
- 'icon' => $urlGenerator->imagePath('settings', 'admin.svg'),
- ]);
- }
-
- // User management is conditionally enabled for subadmins, but appinfo currently only supports full admins
- if ($isSubAdmin) {
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'core_users',
- 'order' => 6,
- 'href' => $urlGenerator->linkToRoute('settings.Users.usersList'),
- 'name' => $l->t('Accounts'),
- 'icon' => $urlGenerator->imagePath('settings', 'users.svg'),
- ]);
- }
-
- // conditionally enabled navigation entry
- if ($config->getSystemValueBool('knowledgebaseenabled', true)) {
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'help',
- 'order' => 99998,
- 'href' => $urlGenerator->linkToRoute('settings.Help.help'),
- 'name' => $l->t('Help & privacy'),
- 'icon' => $urlGenerator->imagePath('settings', 'help.svg'),
- ]);
- }
}
}
diff --git a/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php b/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php
new file mode 100644
index 0000000000000..fc8b6c533ecc3
--- /dev/null
+++ b/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php
@@ -0,0 +1,124 @@
+ */
+class LoadAdditionalEntriesListener implements IEventListener {
+
+ public function __construct(
+ private readonly IConfig $config,
+ private readonly IL10N $l10n,
+ private readonly INavigationManager $navigationManager,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ private readonly ISubAdmin $subAdmin,
+ ) {
+ }
+
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadAdditionalEntriesEvent)) {
+ return;
+ }
+
+ $this->registerNavigationEntries();
+ }
+
+ /**
+ * Registers the navigation entries for the user settings.
+ * Needed as some entries are dynamic and thus we cannot use the appinfo/info.xml
+ *
+ * Registers the following entries:
+ * - Appearance and accessibility
+ * - Personal settings (named "Settings" for non-admins)
+ * - Accounts (only for subadmins)
+ * - Help & privacy (conditionally enabled based on config)
+ */
+ private function registerNavigationEntries(): void {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return;
+ }
+
+ $isAdmin = $this->groupManager->isAdmin($user->getUID());
+ $isSubAdmin = $this->subAdmin->isSubAdmin($user);
+
+ // Accessibility settings - the URL is dynamic (route parameters) which is currently not supported by appinfo.xml
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'accessibility_settings',
+ 'order' => 2,
+ 'href' => $this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'theming']),
+ 'name' => $this->l10n->t('Appearance and accessibility'),
+ 'icon' => $this->urlGenerator->imagePath('theming', 'accessibility-dark.svg'),
+ ]);
+
+ // Personal settings - this entry is dynamic so we cannot use appinfo
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'settings_personal',
+ 'order' => 3,
+ 'href' => $this->urlGenerator->linkToRoute('settings.PersonalSettings.index'),
+ 'name' => $isAdmin
+ ? $this->l10n->t('Personal settings')
+ : $this->l10n->t('Settings'),
+ 'icon' => $isAdmin
+ ? $this->urlGenerator->imagePath('settings', 'personal.svg')
+ : $this->urlGenerator->imagePath('settings', 'admin.svg'),
+ ]);
+
+ if ($isAdmin) {
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'settings_administration',
+ 'order' => 4,
+ 'href' => $this->urlGenerator->linkToRoute('settings.adminSettings.index'),
+ 'name' => $this->l10n->t('Administration settings'),
+ 'icon' => $this->urlGenerator->imagePath('settings', 'admin.svg'),
+ ]);
+ }
+
+ // User management is conditionally enabled for subadmins, but appinfo currently only supports full admins
+ if ($isSubAdmin) {
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'core_users',
+ 'order' => 6,
+ 'href' => $this->urlGenerator->linkToRoute('settings.Users.usersList'),
+ 'name' => $this->l10n->t('Accounts'),
+ 'icon' => $this->urlGenerator->imagePath('settings', 'users.svg'),
+ ]);
+ }
+
+ // conditionally enabled navigation entry
+ if ($this->config->getSystemValueBool('knowledgebaseenabled', true)) {
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'help',
+ 'order' => 99998,
+ 'href' => $this->urlGenerator->linkToRoute('settings.Help.help'),
+ 'name' => $this->l10n->t('Help & privacy'),
+ 'icon' => $this->urlGenerator->imagePath('settings', 'help.svg'),
+ ]);
+ }
+ }
+
+}
diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php
index fd396fa7f08dc..51425946a2536 100644
--- a/core/AppInfo/Application.php
+++ b/core/AppInfo/Application.php
@@ -21,6 +21,7 @@
use OC\Core\Listener\AddMissingIndicesListener;
use OC\Core\Listener\AddMissingPrimaryKeyListener;
use OC\Core\Listener\BeforeTemplateRenderedListener;
+use OC\Core\Listener\LoadAdditionalEntriesListener;
use OC\Core\Listener\PasswordUpdatedListener;
use OC\Core\Listener\RestrictInteractionListener;
use OC\Core\Notification\CoreNotifier;
@@ -48,11 +49,8 @@
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\DB\Events\AddMissingPrimaryKeyEvent;
use OCP\IAppConfig;
-use OCP\INavigationManager;
use OCP\Interaction\RestrictInteractionEvent;
-use OCP\IURLGenerator;
-use OCP\IUserSession;
-use OCP\L10N\IFactory;
+use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
use OCP\Server;
use OCP\Sharing\ISharingRegistry;
use OCP\User\Events\BeforeUserDeletedEvent;
@@ -91,6 +89,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(AddMissingPrimaryKeyEvent::class, AddMissingPrimaryKeyListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(BeforeLoginTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class);
$context->registerEventListener(RemoteWipeStarted::class, RemoteWipeActivityListener::class);
$context->registerEventListener(RemoteWipeStarted::class, RemoteWipeNotificationsListener::class);
$context->registerEventListener(RemoteWipeStarted::class, RemoteWipeEmailListener::class);
@@ -157,34 +156,5 @@ public function register(IRegistrationContext $context): void {
#[\Override]
public function boot(IBootContext $context): void {
- $context->injectFn($this->registerNavigationEntries(...));
- }
-
- /**
- * Registers the navigation entries for the core app:
- * - The logout button in the settings menu
- */
- public function registerNavigationEntries(
- INavigationManager $navigationManager,
- IUserSession $userSession,
- IURLGenerator $urlGenerator,
- IFactory $factory,
- ): void {
- if (!$userSession->isLoggedIn()) {
- return;
- }
-
- $l = $factory->get('core');
-
- // Register the logout button in the user settings
- $logoutUrl = $urlGenerator->getLogoutUrl();
- $navigationManager->add([
- 'type' => 'settings',
- 'id' => 'logout',
- 'order' => 99999,
- 'href' => $logoutUrl,
- 'name' => $l->t('Log out'),
- 'icon' => $urlGenerator->imagePath('core', 'actions/logout.svg'),
- ]);
}
}
diff --git a/core/Listener/LoadAdditionalEntriesListener.php b/core/Listener/LoadAdditionalEntriesListener.php
new file mode 100644
index 0000000000000..0bff035554505
--- /dev/null
+++ b/core/Listener/LoadAdditionalEntriesListener.php
@@ -0,0 +1,55 @@
+ */
+class LoadAdditionalEntriesListener implements IEventListener {
+ private readonly IL10N $l10n;
+
+ public function __construct(
+ public readonly IFactory $l10nFactory,
+ private readonly INavigationManager $navigationManager,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly IUserSession $userSession,
+ ) {
+ $this->l10n = $this->l10nFactory->get('core');
+ }
+
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadAdditionalEntriesEvent)) {
+ return;
+ }
+
+ if (!$this->userSession->isLoggedIn()) {
+ return;
+ }
+
+ // Register the logout button in the user settings
+ $logoutUrl = $this->urlGenerator->getLogoutUrl();
+ $this->navigationManager->add([
+ 'type' => 'settings',
+ 'id' => 'logout',
+ 'order' => 99999,
+ 'href' => $logoutUrl,
+ 'name' => $this->l10n->t('Log out'),
+ 'icon' => $this->urlGenerator->imagePath('core', 'actions/logout.svg'),
+ ]);
+ }
+
+}
diff --git a/lib/OC.php b/lib/OC.php
index a39f15f0bba4b..5b72d243ded14 100644
--- a/lib/OC.php
+++ b/lib/OC.php
@@ -7,6 +7,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+use OC\NavigationManager;
use OC\Profiler\BuiltInProfiler;
use OC\Security\CSP\ContentSecurityPolicyNonceManager;
use OC\Share20\GroupDeletedListener;
@@ -14,6 +15,7 @@
use OC\Share20\UserDeletedListener;
use OC\Share20\UserRemovedListener;
use OC\User\DisabledUserException;
+use OCP\App\Events\AppsLoadedEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\BeforeFileSystemSetupEvent;
use OCP\Group\Events\GroupDeletedEvent;
@@ -1203,6 +1205,9 @@ public static function handleRequest(): void {
}
// All apps are now loaded to handle the request
+ Server::get(NavigationManager::class)->setup();
+ Server::get(IEventDispatcher::class)->dispatchTyped(new AppsLoadedEvent());
+
// if we are not on CLI, try to match the request to a route and handle it
if (!self::$CLI) {
try {
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 8b8de8c3ca5db..beafb4700c69d 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -158,6 +158,7 @@
'OCP\\App\\Events\\AppDisableEvent' => $baseDir . '/lib/public/App/Events/AppDisableEvent.php',
'OCP\\App\\Events\\AppEnableEvent' => $baseDir . '/lib/public/App/Events/AppEnableEvent.php',
'OCP\\App\\Events\\AppUpdateEvent' => $baseDir . '/lib/public/App/Events/AppUpdateEvent.php',
+ 'OCP\\App\\Events\\AppsLoadedEvent' => $baseDir . '/lib/public/App/Events/AppsLoadedEvent.php',
'OCP\\App\\IAppManager' => $baseDir . '/lib/public/App/IAppManager.php',
'OCP\\App\\ManagerEvent' => $baseDir . '/lib/public/App/ManagerEvent.php',
'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php',
@@ -1587,6 +1588,7 @@
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php',
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/core/Listener/BeforeTemplateRenderedListener.php',
'OC\\Core\\Listener\\FeedBackHandler' => $baseDir . '/core/Listener/FeedBackHandler.php',
+ 'OC\\Core\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/core/Listener/LoadAdditionalEntriesListener.php',
'OC\\Core\\Listener\\PasswordUpdatedListener' => $baseDir . '/core/Listener/PasswordUpdatedListener.php',
'OC\\Core\\Listener\\RestrictInteractionListener' => $baseDir . '/core/Listener/RestrictInteractionListener.php',
'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 33a003ab19ace..e024c1fb44b9f 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -199,6 +199,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\App\\Events\\AppDisableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppDisableEvent.php',
'OCP\\App\\Events\\AppEnableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppEnableEvent.php',
'OCP\\App\\Events\\AppUpdateEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppUpdateEvent.php',
+ 'OCP\\App\\Events\\AppsLoadedEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppsLoadedEvent.php',
'OCP\\App\\IAppManager' => __DIR__ . '/../../..' . '/lib/public/App/IAppManager.php',
'OCP\\App\\ManagerEvent' => __DIR__ . '/../../..' . '/lib/public/App/ManagerEvent.php',
'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php',
@@ -1628,6 +1629,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php',
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeTemplateRenderedListener.php',
'OC\\Core\\Listener\\FeedBackHandler' => __DIR__ . '/../../..' . '/core/Listener/FeedBackHandler.php',
+ 'OC\\Core\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/../../..' . '/core/Listener/LoadAdditionalEntriesListener.php',
'OC\\Core\\Listener\\PasswordUpdatedListener' => __DIR__ . '/../../..' . '/core/Listener/PasswordUpdatedListener.php',
'OC\\Core\\Listener\\RestrictInteractionListener' => __DIR__ . '/../../..' . '/core/Listener/RestrictInteractionListener.php',
'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php',
diff --git a/lib/private/NavigationManager.php b/lib/private/NavigationManager.php
index 468e2617d67f0..4942ba474936f 100644
--- a/lib/private/NavigationManager.php
+++ b/lib/private/NavigationManager.php
@@ -166,10 +166,14 @@ private function proceedNavigation(array $list, string $type): array {
/**
* removes all the entries
*/
- public function clear(bool $loadDefaultLinks = true): void {
+ public function clear(bool $resetInit = true): void {
$this->entries = [];
$this->closureEntries = [];
- $this->init = !$loadDefaultLinks;
+
+ if ($resetInit) {
+ $this->loadedAppInfo = [];
+ $this->init = false;
+ }
}
#[Override]
@@ -203,20 +207,25 @@ private function init(): void {
}
/**
- * Resolve the app navigation entries from closures and info.xml files.
+ * Setup the navigation manager.
+ * @internal - This is only used by Nextcloud core to setup the navigation manager. It is not intended for use by apps.
*/
- private function resolveAppNavigationEntries(): void {
+ public function setup(): void {
// Resolve app navigation closures
while ($c = array_pop($this->closureEntries)) {
$this->add($c());
}
// Resolve dynamically added navigation entries via event listeners
- if ($this->loadedAppInfo === []) {
- $this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent());
- }
+ $this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent());
+ }
- // Resolve classic info.xml based navigation entries
+ /**
+ * Resolve classic info.xml based navigation entires.
+ * Some code relies on this to be available earlier then the app loading finished.
+ * So we need to resolve the navigation entries here, even if not all apps are loaded yet.
+ */
+ private function resolveAppNavigationEntries(): void {
if ($this->userSession->isLoggedIn()) {
$user = $this->userSession->getUser();
$apps = $this->appManager->getEnabledAppsForUser($user);
@@ -225,16 +234,23 @@ private function resolveAppNavigationEntries(): void {
}
foreach ($apps as $app) {
- // skip already loaded apps
- if (in_array($app, $this->loadedAppInfo)) {
+ if (in_array($app, $this->loadedAppInfo, true)) {
+ // already loaded
+ continue;
+ }
+ if (!$this->appManager->isAppLoaded($app)) {
+ // app is not loaded yet, skip it
continue;
}
// load plugins and collections from info.xml
$info = $this->appManager->getAppInfo($app);
if (!isset($info['navigations']['navigation'])) {
+ // this app does not have any navigation entries, skip it
+ $this->loadedAppInfo[] = $app;
continue;
}
+
foreach ($info['navigations']['navigation'] as $key => $nav) {
$nav['type'] = $nav['type'] ?? 'link';
if (!isset($nav['name'])) {
@@ -250,8 +266,11 @@ private function resolveAppNavigationEntries(): void {
}
$id = $nav['id'] ?? $app . ($key === 0 ? '' : $key);
$order = $nav['order'] ?? 100;
- $type = $nav['type'];
- $route = !empty($nav['route']) ? $this->urlGenerator->linkToRoute($nav['route']) : '';
+ $type = $nav['type'] ?? 'link';
+ $route = $nav['route'] ?? '';
+ if ($route !== '') {
+ $route = $this->urlGenerator->linkToRoute($route);
+ }
$icon = $nav['icon'] ?? null;
if ($icon !== null) {
try {
diff --git a/lib/public/App/Events/AppsLoadedEvent.php b/lib/public/App/Events/AppsLoadedEvent.php
new file mode 100644
index 0000000000000..912474d0ad4c1
--- /dev/null
+++ b/lib/public/App/Events/AppsLoadedEvent.php
@@ -0,0 +1,21 @@
+assertEquals(0, $testAddClosureNumberOfCalls, 'Expected that the closure is not called by add()');
+ $this->navigationManager->setup();
$navigationEntries = $this->navigationManager->getAll('all');
$this->assertEquals(1, $testAddClosureNumberOfCalls, 'Expected that the closure is called by getAll()');
$this->assertCount(1, $navigationEntries, 'Expected that 1 navigation entry exists');
@@ -233,6 +234,12 @@ public function testWithAppManager($expected, $navigation, $isAdmin = false): vo
->method('getAppInfo')
->with('test')
->willReturn($navigation);
+ $this->appManager->expects($this->any())
+ ->method('isAppLoaded')
+ ->willReturnMap([
+ ['test', true],
+ ['files', true],
+ ]);
$this->urlGenerator->expects($this->any())
->method('imagePath')
->willReturnCallback(function ($appName, $file) {
@@ -259,11 +266,12 @@ public function testWithAppManager($expected, $navigation, $isAdmin = false): vo
$this->groupManager->expects($this->any())->method('isAdmin')->willReturn($isAdmin);
$this->navigationManager->clear();
- $this->dispatcher->expects($this->once())
+ $this->dispatcher->expects($this->atLeastOnce())
->method('dispatchTyped')
->willReturnCallback(function ($event): void {
$this->assertInstanceOf(LoadAdditionalEntriesEvent::class, $event);
});
+ $this->navigationManager->setup();
$entries = $this->navigationManager->getAll('all');
$this->assertEquals($expected, $entries);
}
@@ -427,8 +435,20 @@ function (string $userId, string $appName, string $key, mixed $default = '') use
->method('isEnabledForUser')
->with('theming')
->willReturn(true);
- $this->appManager->expects($this->once())->method('getAppInfo')->with('test')->willReturn($navigation);
- $this->appManager->expects($this->once())->method('getAppIcon')->with('test')->willReturn('/apps/test/img/app.svg');
+ $this->appManager->expects($this->once())
+ ->method('getAppIcon')
+ ->with('test')
+ ->willReturn('/apps/test/img/app.svg');
+ $this->appManager->expects($this->once())
+ ->method('getAppInfo')
+ ->with('test')
+ ->willReturn($navigation);
+ $this->appManager->expects($this->atLeastOnce())
+ ->method('isAppLoaded')
+ ->willReturnMap([
+ ['test', true],
+ ['files', true],
+ ]);
$this->l10nFac->expects($this->any())->method('get')->willReturn($l);
$this->urlGenerator->expects($this->any())->method('imagePath')->willReturnCallback(function ($appName, $file) {
return "/apps/$appName/img/$file";
@@ -455,10 +475,106 @@ function (string $userId, string $appName, string $key, mixed $default = '') use
->willReturnCallback(function ($event): void {
$this->assertInstanceOf(LoadAdditionalEntriesEvent::class, $event);
});
+ $this->navigationManager->setup();
$entries = $this->navigationManager->getAll();
$this->assertEquals($expected, $entries);
}
+ /**
+ * Navigation entries of enabled apps that are not booted yet must not be resolved.
+ */
+ public function testResolveOnlyLoadedApps(): void {
+ /* Return default value */
+ $this->config->method('getUserValue')->willReturnArgument(3);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('user001');
+ $this->userSession->method('getUser')->willReturn($user);
+ $this->userSession->method('isLoggedIn')->willReturn(true);
+ $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']);
+
+ // The app is enabled but not booted yet ...
+ $this->appManager->expects($this->atLeastOnce())
+ ->method('isAppLoaded')
+ ->with('test')
+ ->willReturn(false);
+ // ... so its info.xml navigation entries must never be read
+ $this->appManager->expects($this->never())->method('getAppInfo');
+
+ $this->navigationManager->clear();
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+ }
+
+ /**
+ * The LoadAdditionalEntriesEvent is only dispatched by setup(), not by getAll().
+ */
+ public function testGetAllDoesNotDispatchAdditionalEntries(): void {
+ $this->userSession->method('isLoggedIn')->willReturn(false);
+ $this->appManager->method('getEnabledApps')->willReturn([]);
+
+ $this->dispatcher->expects($this->never())->method('dispatchTyped');
+
+ $this->navigationManager->clear();
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+ }
+
+ /**
+ * An app's info.xml must only be resolved once, even across multiple getAll() calls
+ * and even when the app does not provide any navigation entries.
+ */
+ public function testAppInfoResolvedOnlyOnce(): void {
+ $this->config->method('getUserValue')->willReturnArgument(3);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('user001');
+ $this->userSession->method('getUser')->willReturn($user);
+ $this->userSession->method('isLoggedIn')->willReturn(true);
+ $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']);
+ $this->appManager->method('isAppLoaded')->with('test')->willReturn(true);
+
+ // App has no navigation entries; info.xml must only be read once
+ $this->appManager->expects($this->once())
+ ->method('getAppInfo')
+ ->with('test')
+ ->willReturn(['navigations' => []]);
+
+ $this->navigationManager->clear();
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+ }
+
+ /**
+ * clear(false) keeps the resolved state, so already loaded apps are not resolved again;
+ * clear(true) resets it, forcing a fresh resolve.
+ */
+ public function testClearResetsResolvedStateOnlyWhenRequested(): void {
+ $this->config->method('getUserValue')->willReturnArgument(3);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('user001');
+ $this->userSession->method('getUser')->willReturn($user);
+ $this->userSession->method('isLoggedIn')->willReturn(true);
+ $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']);
+ $this->appManager->method('isAppLoaded')->with('test')->willReturn(true);
+
+ // Resolved once for the initial getAll(), then again after clear(true) resets the state
+ $this->appManager->expects($this->exactly(2))
+ ->method('getAppInfo')
+ ->with('test')
+ ->willReturn(['navigations' => []]);
+
+ $this->navigationManager->clear();
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+
+ // Soft clear keeps the resolved state, so getAppInfo is not called again
+ $this->navigationManager->clear(false);
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+
+ // Full clear resets the resolved state, so the app is resolved again
+ $this->navigationManager->clear(true);
+ $this->assertEquals([], $this->navigationManager->getAll('all'));
+ }
+
public static function provideDefaultEntries(): array {
return [
// none specified, default to files
@@ -630,7 +746,13 @@ public function testGetDefaultEntryIdForUser(string $defaultApps, string $userDe
];
});
- $this->appManager->method('getEnabledApps')->willReturn([]);
+ $this->appManager->method('getEnabledApps')->willReturn(['files']);
+ $this->appManager->expects($this->atLeastOnce())
+ ->method('isAppLoaded')
+ ->willReturnMap([
+ ['test', true],
+ ['files', true],
+ ]);
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
@@ -651,6 +773,7 @@ public function testGetDefaultEntryIdForUser(string $defaultApps, string $userDe
['user1', 'core', 'apporder', '[]', $userApporder],
]);
+ $this->navigationManager->setup();
$this->assertEquals($expectedApp, $this->navigationManager->getDefaultEntryIdForUser(null, $withFallbacks));
}