Merge pull request #43967 from nextcloud/feat/app-updated-notification

feat: Provide app-updated notifications for users
pull/44062/head
John Molakvoæ 3 months ago committed by GitHub
commit cf888e9723
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,9 +3,9 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>updatenotification</id>
<name>Update notification</name>
<summary>Displays update notifications for Nextcloud and provides the SSO for the updater.</summary>
<description>Displays update notifications for Nextcloud and provides the SSO for the updater.</description>
<version>1.19.0</version>
<summary>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</summary>
<description>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</description>
<version>1.19.1</version>
<licence>agpl</licence>
<author>Lukas Reschke</author>
<namespace>UpdateNotification</namespace>
@ -16,7 +16,7 @@
</dependencies>
<background-jobs>
<job>OCA\UpdateNotification\Notification\BackgroundJob</job>
<job>OCA\UpdateNotification\BackgroundJob\UpdateAvailableNotifications</job>
</background-jobs>
<settings>

@ -27,8 +27,11 @@ return [
'routes' => [
['name' => 'Admin#createCredentials', 'url' => '/credentials', 'verb' => 'GET'],
['name' => 'Admin#setChannel', 'url' => '/channel', 'verb' => 'POST'],
// Fallback app changelog information for mobile clients
['name' => 'Changelog#showChangelog', 'url' => '/changelog/{app}', 'verb' => 'GET'],
],
'ocs' => [
['name' => 'API#getAppList', 'url' => '/api/{apiVersion}/applist/{newVersion}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
['name' => 'API#getAppChangelogEntry', 'url' => '/api/{apiVersion}/changelog/{appId}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
],
];

@ -8,12 +8,19 @@ $baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => $baseDir . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => $baseDir . '/../lib/BackgroundJob/ResetToken.php',
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => $baseDir . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => $baseDir . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => $baseDir . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => $baseDir . '/../lib/Controller/AdminController.php',
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => $baseDir . '/../lib/Notification/BackgroundJob.php',
'OCA\\UpdateNotification\\Controller\\ChangelogController' => $baseDir . '/../lib/Controller/ChangelogController.php',
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => $baseDir . '/../lib/Listener/AppUpdateEventListener.php',
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => $baseDir . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
'OCA\\UpdateNotification\\Manager' => $baseDir . '/../lib/Manager.php',
'OCA\\UpdateNotification\\Migration\\Version011901Date20240305120000' => $baseDir . '/../lib/Migration/Version011901Date20240305120000.php',
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => $baseDir . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => $baseDir . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => $baseDir . '/../lib/UpdateChecker.php',

@ -23,12 +23,19 @@ class ComposerStaticInitUpdateNotification
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => __DIR__ . '/..' . '/../lib/BackgroundJob/ResetToken.php',
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => __DIR__ . '/..' . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => __DIR__ . '/..' . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => __DIR__ . '/..' . '/../lib/Controller/AdminController.php',
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => __DIR__ . '/..' . '/../lib/Notification/BackgroundJob.php',
'OCA\\UpdateNotification\\Controller\\ChangelogController' => __DIR__ . '/..' . '/../lib/Controller/ChangelogController.php',
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => __DIR__ . '/..' . '/../lib/Listener/AppUpdateEventListener.php',
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => __DIR__ . '/..' . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
'OCA\\UpdateNotification\\Manager' => __DIR__ . '/..' . '/../lib/Manager.php',
'OCA\\UpdateNotification\\Migration\\Version011901Date20240305120000' => __DIR__ . '/..' . '/../lib/Migration/Version011901Date20240305120000.php',
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => __DIR__ . '/..' . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => __DIR__ . '/..' . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => __DIR__ . '/..' . '/../lib/UpdateChecker.php',

@ -10,6 +10,7 @@ declare(strict_types=1);
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@ -29,29 +30,40 @@ declare(strict_types=1);
*/
namespace OCA\UpdateNotification\AppInfo;
use OCA\UpdateNotification\Listener\AppUpdateEventListener;
use OCA\UpdateNotification\Listener\BeforeTemplateRenderedEventListener;
use OCA\UpdateNotification\Notification\AppUpdateNotifier;
use OCA\UpdateNotification\Notification\Notifier;
use OCA\UpdateNotification\UpdateChecker;
use OCP\App\Events\AppUpdateEvent;
use OCP\App\IAppManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\IAppContainer;
use OCP\AppFramework\QueryException;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Util;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
class Application extends App implements IBootstrap {
public const APP_NAME = 'updatenotification';
public function __construct() {
parent::__construct('updatenotification', []);
parent::__construct(self::APP_NAME, []);
}
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(Notifier::class);
$context->registerNotifierService(AppUpdateNotifier::class);
$context->registerEventListener(AppUpdateEvent::class, AppUpdateEventListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedEventListener::class);
}
public function boot(IBootContext $context): void {
@ -59,7 +71,7 @@ class Application extends App implements IBootstrap {
IUserSession $userSession,
IAppManager $appManager,
IGroupManager $groupManager,
IAppContainer $appContainer,
ContainerInterface $container,
LoggerInterface $logger) {
if ($config->getSystemValue('updatechecker', true) !== true) {
// Updater check is disabled
@ -75,8 +87,8 @@ class Application extends App implements IBootstrap {
if (!$appManager->isEnabledForUser('notifications') &&
$groupManager->isAdmin($user->getUID())) {
try {
$updateChecker = $appContainer->get(UpdateChecker::class);
} catch (QueryException $e) {
$updateChecker = $container->get(UpdateChecker::class);
} catch (ContainerExceptionInterface $e) {
$logger->error($e->getMessage(), ['exception' => $e]);
return;
}

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\BackgroundJob;
use OCA\UpdateNotification\AppInfo\Application;
use OCA\UpdateNotification\Manager;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Notification\IManager;
use OCP\Notification\INotification;
use Psr\Log\LoggerInterface;
class AppUpdatedNotifications extends QueuedJob {
public function __construct(
ITimeFactory $time,
private IConfig $config,
private IAppConfig $appConfig,
private IManager $notificationManager,
private IUserManager $userManager,
private IAppManager $appManager,
private LoggerInterface $logger,
private Manager $manager,
) {
parent::__construct($time);
}
/**
* @param array{appId: string, timestamp: int} $argument
*/
protected function run(mixed $argument): void {
$appId = $argument['appId'];
$timestamp = $argument['timestamp'];
$dateTime = $this->time->getDateTime();
$dateTime->setTimestamp($timestamp);
$this->logger->debug(
'Running background job to create app update notifications for "' . $appId . '"',
[
'app' => Application::APP_NAME,
],
);
if ($this->manager->getChangelogFile($appId, 'en') === null) {
$this->logger->debug('Skipping app updated notification - no changelog provided');
return;
}
$this->stopPreviousNotifications($appId);
// Create new notifications
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_NAME)
->setDateTime($dateTime)
->setSubject('app_updated', [$appId])
->setObject('app_updated', $appId);
$this->notifyUsers($appId, $notification);
}
/**
* Stop all previous notifications users might not have dismissed until now
* @param string $appId The app to stop update notifications for
*/
private function stopPreviousNotifications(string $appId): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_NAME)
->setObject('app_updated', $appId);
$this->notificationManager->markProcessed($notification);
}
/**
* Notify all users for which the updated app is enabled
*/
private function notifyUsers(string $appId, INotification $notification): void {
$guestsEnabled = $this->appConfig->getAppValueBool('app_updated.notify_guests', false) && class_exists('\OCA\Guests\UserBackend');
$isDefer = $this->notificationManager->defer();
// Notify all seen users about the app update
$this->userManager->callForSeenUsers(function (IUser $user) use ($guestsEnabled, $appId, $notification) {
if (!$guestsEnabled && ($user->getBackendClassName() === '\OCA\Guests\UserBackend')) {
return;
}
if (!$this->appManager->isEnabledForUser($appId, $user)) {
return;
}
$notification->setUser($user->getUID());
$this->notificationManager->notify($notification);
});
// If we enabled the defer we call the flush
if ($isDefer) {
$this->notificationManager->flush();
}
}
}

@ -8,6 +8,7 @@ declare(strict_types=1);
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0
*
@ -24,43 +25,43 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\UpdateNotification;
namespace OCA\UpdateNotification\BackgroundJob;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\IAppConfig;
use OCP\IConfig;
/**
* Class ResetTokenBackgroundJob deletes any configured token all 24 hours for
*
*
* @package OCA\UpdateNotification
* Deletes the updater secret after if it is older than 48h
*/
class ResetTokenBackgroundJob extends TimedJob {
/** @var IConfig */
private $config;
/** @var ITimeFactory */
private $timeFactory;
class ResetToken extends TimedJob {
/**
* @param IConfig $config
* @param ITimeFactory $timeFactory
*/
public function __construct(IConfig $config,
ITimeFactory $timeFactory) {
parent::__construct($timeFactory);
public function __construct(
ITimeFactory $time,
private IConfig $config,
private IAppConfig $appConfig,
) {
parent::__construct($time);
// Run all 10 minutes
parent::setInterval(60 * 10);
$this->config = $config;
$this->timeFactory = $timeFactory;
}
/**
* @param $argument
*/
protected function run($argument) {
if ($this->config->getSystemValueBool('config_is_read_only')) {
return;
}
$secretCreated = $this->appConfig->getValueInt('core', 'updater.secret.created', $this->time->getTime());
// Delete old tokens after 2 days
if ($this->config->getSystemValueBool('config_is_read_only') === false && $this->timeFactory->getTime() - (int) $this->config->getAppValue('core', 'updater.secret.created', (string) $this->timeFactory->getTime()) >= 172800) {
if ($secretCreated >= 172800) {
$this->config->deleteSystemValue('updater.secret');
}
}

@ -8,6 +8,7 @@ declare(strict_types=1);
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0
*
@ -24,11 +25,12 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\UpdateNotification\Notification;
namespace OCA\UpdateNotification\BackgroundJob;
use OC\Installer;
use OC\Updater\VersionCheck;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\IConfig;
@ -36,7 +38,7 @@ use OCP\IGroup;
use OCP\IGroupManager;
use OCP\Notification\IManager;
class BackgroundJob extends TimedJob {
class UpdateAvailableNotifications extends TimedJob {
protected $connectionNotifications = [3, 7, 14, 30];
/** @var string[] */
@ -45,6 +47,7 @@ class BackgroundJob extends TimedJob {
public function __construct(
ITimeFactory $timeFactory,
protected IConfig $config,
protected IAppConfig $appConfig,
protected IManager $notificationManager,
protected IGroupManager $groupManager,
protected IAppManager $appManager,
@ -87,14 +90,14 @@ class BackgroundJob extends TimedJob {
$status = $this->versionCheck->check();
if ($status === false) {
$errors = 1 + (int) $this->config->getAppValue('updatenotification', 'update_check_errors', '0');
$this->config->setAppValue('updatenotification', 'update_check_errors', (string) $errors);
$errors = 1 + $this->appConfig->getAppValueInt('update_check_errors', 0);
$this->appConfig->setAppValueInt('update_check_errors', $errors);
if (\in_array($errors, $this->connectionNotifications, true)) {
$this->sendErrorNotifications($errors);
}
} elseif (\is_array($status)) {
$this->config->setAppValue('updatenotification', 'update_check_errors', '0');
$this->appConfig->setAppValueInt('update_check_errors', 0);
$this->clearErrorNotifications();
if (isset($status['version'])) {
@ -162,13 +165,13 @@ class BackgroundJob extends TimedJob {
* @param string $visibleVersion
*/
protected function createNotifications($app, $version, $visibleVersion = '') {
$lastNotification = $this->config->getAppValue('updatenotification', $app, false);
$lastNotification = $this->appConfig->getAppValueString($app, '');
if ($lastNotification === $version) {
// We already notified about this update
return;
}
if ($lastNotification !== false) {
if ($lastNotification !== '') {
// Delete old updates
$this->deleteOutdatedNotifications($app, $lastNotification);
}
@ -193,7 +196,7 @@ class BackgroundJob extends TimedJob {
return;
}
$this->config->setAppValue('updatenotification', $app, $version);
$this->appConfig->setAppValueString($app, $version);
}
/**
@ -204,20 +207,18 @@ class BackgroundJob extends TimedJob {
return $this->users;
}
$notifyGroups = (array) json_decode($this->config->getAppValue('updatenotification', 'notify_groups', '["admin"]'), true);
$notifyGroups = $this->appConfig->getAppValueArray('notify_groups', ['admin']);
$this->users = [];
foreach ($notifyGroups as $group) {
$groupToNotify = $this->groupManager->get($group);
if ($groupToNotify instanceof IGroup) {
foreach ($groupToNotify->getUsers() as $user) {
$this->users[$user->getUID()] = true;
$this->users[] = $user->getUID();
}
}
}
$this->users = array_keys($this->users);
return $this->users;
return array_unique($this->users);
}
/**

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@ -27,6 +28,7 @@ declare(strict_types=1);
namespace OCA\UpdateNotification\Controller;
use OC\App\AppStore\Fetcher\AppFetcher;
use OCA\UpdateNotification\Manager;
use OCA\UpdateNotification\ResponseDefinitions;
use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
@ -43,21 +45,6 @@ use OCP\L10N\IFactory;
*/
class APIController extends OCSController {
/** @var IConfig */
protected $config;
/** @var IAppManager */
protected $appManager;
/** @var AppFetcher */
protected $appFetcher;
/** @var IFactory */
protected $l10nFactory;
/** @var IUserSession */
protected $userSession;
/** @var string */
protected $language;
@ -73,20 +60,17 @@ class APIController extends OCSController {
'twofactor_totp' => 25,
];
public function __construct(string $appName,
public function __construct(
string $appName,
IRequest $request,
IConfig $config,
IAppManager $appManager,
AppFetcher $appFetcher,
IFactory $l10nFactory,
IUserSession $userSession) {
protected IConfig $config,
protected IAppManager $appManager,
protected AppFetcher $appFetcher,
protected IFactory $l10nFactory,
protected IUserSession $userSession,
protected Manager $manager,
) {
parent::__construct($appName, $request);
$this->config = $config;
$this->appManager = $appManager;
$this->appFetcher = $appFetcher;
$this->l10nFactory = $l10nFactory;
$this->userSession = $userSession;
}
/**
@ -178,4 +162,40 @@ class APIController extends OCSController {
'appName' => $name ?? $appId,
];
}
/**
* Get changelog entry for an app
*
* @param string $appId App to search changelog entry for
* @param string|null $version The version to search the changelog entry for (defaults to the latest installed)
*
* @return DataResponse<Http::STATUS_OK, array{appName: string, content: string, version: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
*
* 200: Changelog entry returned
* 404: No changelog found
*/
public function getAppChangelogEntry(string $appId, ?string $version = null): DataResponse {
$version = $version ?? $this->appManager->getAppVersion($appId);
$changes = $this->manager->getChangelog($appId, $version);
if ($changes === null) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
// Remove version headline
/** @var string[] */
$changes = explode("\n", $changes, 2);
$changes = trim(end($changes));
// Get app info for localized app name
$info = $this->appManager->getAppInfo($appId) ?? [];
/** @var string */
$appName = $info['name'] ?? $appId;
return new DataResponse([
'appName' => $appName,
'content' => $changes,
'version' => $version,
]);
}
}

@ -9,6 +9,7 @@ declare(strict_types=1);
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Vincent Petry <vincent@nextcloud.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0
*
@ -27,12 +28,13 @@ declare(strict_types=1);
*/
namespace OCA\UpdateNotification\Controller;
use OCA\UpdateNotification\ResetTokenBackgroundJob;
use OCA\UpdateNotification\BackgroundJob\ResetToken;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
@ -40,39 +42,18 @@ use OCP\Security\ISecureRandom;
use OCP\Util;
class AdminController extends Controller {
/** @var IJobList */
private $jobList;
/** @var ISecureRandom */
private $secureRandom;
/** @var IConfig */
private $config;
/** @var ITimeFactory */
private $timeFactory;
/** @var IL10N */
private $l10n;
/**
* @param string $appName
* @param IRequest $request
* @param IJobList $jobList
* @param ISecureRandom $secureRandom
* @param IConfig $config
* @param ITimeFactory $timeFactory
* @param IL10N $l10n
*/
public function __construct($appName,
public function __construct(
string $appName,
IRequest $request,
IJobList $jobList,
ISecureRandom $secureRandom,
IConfig $config,
ITimeFactory $timeFactory,
IL10N $l10n) {
private IJobList $jobList,
private ISecureRandom $secureRandom,
private IConfig $config,
private IAppConfig $appConfig,
private ITimeFactory $timeFactory,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
$this->jobList = $jobList;
$this->secureRandom = $secureRandom;
$this->config = $config;
$this->timeFactory = $timeFactory;
$this->l10n = $l10n;
}
private function isUpdaterEnabled() {
@ -85,7 +66,7 @@ class AdminController extends Controller {
*/
public function setChannel(string $channel): DataResponse {
Util::setChannel($channel);
$this->config->setAppValue('core', 'lastupdatedat', '0');
$this->appConfig->setValueInt('core', 'lastupdatedat', 0);
return new DataResponse(['status' => 'success', 'data' => ['message' => $this->l10n->t('Channel updated')]]);
}
@ -98,8 +79,8 @@ class AdminController extends Controller {
}
// Create a new job and store the creation date
$this->jobList->add(ResetTokenBackgroundJob::class);
$this->config->setAppValue('core', 'updater.secret.created', (string)$this->timeFactory->getTime());
$this->jobList->add(ResetToken::class);
$this->appConfig->setValueInt('core', 'updater.secret.created', $this->timeFactory->getTime());
// Create a new token
$newToken = $this->secureRandom->generate(64);

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Controller;
use OCA\UpdateNotification\Manager;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class ChangelogController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private Manager $manager,
private IAppManager $appManager,
private IInitialState $initialState,
) {
parent::__construct($appName, $request);
}
/**
* This page is only used for clients not support showing the app changelog feature in-app and thus need to show it on a dedicated page.
* @param string $app App to show the changelog for
* @param string|null $version Version entry to show (defaults to latest installed)
* @NoCSRFRequired
* @NoAdminRequired
*/
public function showChangelog(string $app, ?string $version = null): TemplateResponse {
$version = $version ?? $this->appManager->getAppVersion($app);
$appInfo = $this->appManager->getAppInfo($app) ?? [];
$appName = $appInfo['name'] ?? $app;
$changes = $this->manager->getChangelog($app, $version) ?? '';
// Remove version headline
/** @var string[] */
$changes = explode("\n", $changes, 2);
$changes = trim(end($changes));
$this->initialState->provideInitialState('changelog', [
'appName' => $appName,
'appVersion' => $version,
'text' => $changes,
]);
\OCP\Util::addScript($this->appName, 'view-changelog-page');
return new TemplateResponse($this->appName, 'empty');
}
}

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Listener;
use OCA\UpdateNotification\AppInfo\Application;
use OCA\UpdateNotification\BackgroundJob\AppUpdatedNotifications;
use OCP\App\Events\AppUpdateEvent;
use OCP\BackgroundJob\IJobList;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
/** @template-implements IEventListener<AppUpdateEvent> */
class AppUpdateEventListener implements IEventListener {
public function __construct(
private IJobList $jobList,
private LoggerInterface $logger,
private IAppConfig $appConfig,
) {
}
/**
* @param AppUpdateEvent $event
*/
public function handle(Event $event): void {
if (!($event instanceof AppUpdateEvent)) {
return;
}
if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
return;
}
foreach ($this->jobList->getJobsIterator(AppUpdatedNotifications::class, null, 0) as $job) {
// Remove waiting notification jobs for this app
if ($job->getArgument()['appId'] === $event->getAppId()) {
$this->jobList->remove($job);
}
}
$this->jobList->add(AppUpdatedNotifications::class, [
'appId' => $event->getAppId(),
'timestamp' => time(),
]);
$this->logger->debug(
'Scheduled app update notification for "' . $event->getAppId() . '"',
[
'app' => Application::APP_NAME,
],
);
}
}

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Listener;
use OCA\UpdateNotification\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class BeforeTemplateRenderedEventListener implements IEventListener {
public function __construct(
private IAppManager $appManager,
private LoggerInterface $logger,
private IAppConfig $appConfig,
) {
}
/**
* @param BeforeTemplateRenderedEvent $event
*/
public function handle(Event $event): void {
if (!($event instanceof BeforeTemplateRenderedEvent)) {
return;
}
if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
return;
}
// Only handle logged in users
if (!$event->isLoggedIn()) {
return;
}
// Ignore when notifications are disabled
if (!$this->appManager->isEnabledForUser('notifications')) {
return;
}
\OCP\Util::addInitScript(Application::APP_NAME, 'init');
}
}

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification;
use OCP\App\IAppManager;
use OCP\IUser;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use Psr\Log\LoggerInterface;
class Manager {
private ?IUser $currentUser;
public function __construct(
IUserSession $currentSession,
private IAppManager $appManager,
private IFactory $l10NFactory,
private LoggerInterface $logger,
) {
$this->currentUser = $currentSession->getUser();
}
/**
* Get the changelog entry for the given appId
* @param string $appId The app for which to query the entry
* @param string $version The version for which to query the changelog entry
* @param ?string $languageCode The language in which to query the changelog (defaults to current user language and fallsback to English)
* @return string|null Either the changelog entry or null if no changelog is found
*/
public function getChangelog(string $appId, string $version, ?string $languageCode = null): string|null {
if ($languageCode === null) {
$languageCode = $this->l10NFactory->getUserLanguage($this->currentUser);
}
$path = $this->getChangelogFile($appId, $languageCode);
if ($path === null) {
$this->logger->debug('No changelog file found for app ' . $appId . ' and language code ' . $languageCode);
return null;
}
$changes = $this->retrieveChangelogEntry($path, $version);
return $changes;
}
/**
* Get the changelog file in the requested language or fallback to English
* @param string $appId The app to load the changelog for
* @param string $languageCode The language code to search
* @return string|null Either the file path or null if not found
*/
public function getChangelogFile(string $appId, string $languageCode): string|null {
try {
$appPath = $this->appManager->getAppPath($appId);
$files = ["CHANGELOG.$languageCode.md", 'CHANGELOG.en.md'];
foreach ($files as $file) {
$path = $appPath . '/' . $file;
if (is_file($path)) {
return $path;
}
}
} catch (\Throwable $e) {
// ignore and return null below
}
return null;
}
/**
* Retrieve a log entry from the changelog
* @param string $path The path to the changlog file
* @param string $version The version to query (make sure to only pass in "{major}.{minor}(.{patch}" format)
*/
protected function retrieveChangelogEntry(string $path, string $version): string|null {
$matches = [];
$content = file_get_contents($path);
if ($content === false) {
$this->logger->debug('Could not open changelog file', ['file-path' => $path]);
return null;
}
$result = preg_match_all('/^## (?:\[)?(?:v)?(\d+\.\d+(\.\d+)?)/m', $content, $matches, PREG_OFFSET_CAPTURE);
if ($result === false || $result === 0) {
$this->logger->debug('No entries in changelog found', ['file_path' => $path]);
return null;
}
// Get the key of the match that equals the requested version
$index = array_key_first(
// Get the array containing the match that equals the requested version, keys are preserved so: [1 => '1.2.4']
array_filter(
// This is the array of the versions found, like ['1.2.3', '1.2.4']
$matches[1],
// Callback to filter only version that matches the requested version
fn (array $match) => version_compare($match[0], $version, '=='),
)
);
if ($index === null) {
$this->logger->debug('No changelog entry for version ' . $version . ' found', ['file_path' => $path]);
return null;
}
$offsetChangelogEntry = $matches[0][$index][1];
// Length of the changelog entry (offset of next match - own offset) or null if the whole rest should be considered
$lengthChangelogEntry = $index < ($result - 1) ? ($matches[0][$index + 1][1] - $offsetChangelogEntry) : null;
return substr($content, $offsetChangelogEntry, $lengthChangelogEntry);
}
}

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Migration;
use OCA\UpdateNotification\BackgroundJob\ResetToken;
use OCP\BackgroundJob\IJobList;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Drop this with Nextcloud 30
*/
class Version011901Date20240305120000 extends SimpleMigrationStep {
public function __construct(
private IJobList $joblist,
) {
}
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void {
/**
* Remove and replace the reset-updater-token background job
* This class was renamed so it is now unknow but we still need to remove it
* @psalm-suppress UndefinedClass, InvalidArgument
*/
$hasOldResetToken = $this->joblist->has(\OCA\UpdateNotification\ResetTokenBackgroundJob::class, null);
$hasNewResetToken = $this->joblist->has(ResetToken::class, null);
if ($hasOldResetToken) {
/**
* @psalm-suppress UndefinedClass, InvalidArgument
*/
$this->joblist->remove(\OCA\UpdateNotification\ResetTokenBackgroundJob::class);
if (!$hasNewResetToken) {
$this->joblist->add(ResetToken::class);
}
}
/**
* Remove the "has updates" background job, the new one is automatically started from the info.xml
* @psalm-suppress UndefinedClass, InvalidArgument
*/
if ($this->joblist->has(\OCA\UpdateNotification\Notification\BackgroundJob::class, null)) {
/**
* @psalm-suppress UndefinedClass, InvalidArgument
*/
$this->joblist->remove(\OCA\UpdateNotification\Notification\BackgroundJob::class);
}
}
}

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
* @author Joas Schilling <coding@schilljs.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Notification;
use OCA\UpdateNotification\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Notification\IAction;
use OCP\Notification\IManager as INotificationManager;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use Psr\Log\LoggerInterface;
class AppUpdateNotifier implements INotifier {
public function __construct(
private IFactory $l10nFactory,
private INotificationManager $notificationManager,
private IUserManager $userManager,
private IURLGenerator $urlGenerator,
private IAppManager $appManager,
private LoggerInterface $logger,
) {
}
public function getID(): string {
return 'updatenotification_app_updated';
}
/**
* Human readable name describing the notifier
*/
public function getName(): string {
return $this->l10nFactory->get(Application::APP_NAME)->t('App updated');
}
/**
* @param INotification $notification
* @param string $languageCode The code of the language that should be used to prepare the notification
* @return INotification
* @throws \InvalidArgumentException When the notification was not prepared by a notifier
*/
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_NAME) {
throw new \InvalidArgumentException('Unknown app');
}
if ($notification->getSubject() !== 'app_updated') {
throw new \InvalidArgumentException('Unknown subject');
}
$appId = $notification->getSubjectParameters()[0];
$appInfo = $this->appManager->getAppInfo($appId, lang:$languageCode);
if ($appInfo === null) {
throw new \InvalidArgumentException('App info not found');
}
// Prepare translation factory for requested language
$l = $this->l10nFactory->get(Application::APP_NAME, $languageCode);
$icon = $this->appManager->getAppIcon($appId);
if ($icon === null) {
$icon = $this->urlGenerator->imagePath('core', 'default-app-icon');
}
$action = $notification->createAction();
$action
->setLabel($l->t('See what\'s new'))
->setParsedLabel($l->t('See what\'s new'))
->setLink($this->urlGenerator->linkToRouteAbsolute('updatenotification.Changelog.showChangelog', ['app' => $appId, 'version' => $this->appManager->getAppVersion($appId)]), IAction::TYPE_WEB);
$notification
->setIcon($this->urlGenerator->getAbsoluteURL($icon))
->addParsedAction($action)
->setRichSubject(
$l->t('{app} updated to version {version}'),
[
'app' => [
'type' => 'app',
'id' => $appId,
'name' => $appInfo['name'],
],
'version' => [
'type' => 'highlight',
'id' => $appId,
'name' => $appInfo['version'],
],
],
);
return $notification;
}
}

@ -114,6 +114,10 @@ class Notifier implements INotifier {
throw new \InvalidArgumentException('Unknown app id');
}
if ($notification->getSubject() !== 'update_available' && $notification->getSubject() !== 'connection_error') {
throw new \InvalidArgumentException('Unknown subject');
}
$l = $this->l10NFactory->get('updatenotification', $languageCode);
if ($notification->getSubject() === 'connection_error') {
$errors = (int) $this->config->getAppValue('updatenotification', 'update_check_errors', '0');
@ -124,12 +128,12 @@ class Notifier implements INotifier {
$notification->setParsedSubject($l->t('The update server could not be reached since %d days to check for new updates.', [$errors]))
->setParsedMessage($l->t('Please check the Nextcloud and server log files for errors.'));
} elseif ($notification->getObjectType() === 'core') {
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
} else {
if ($notification->getObjectType() === 'core') {
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
$parameters = $notification->getSubjectParameters();
$notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
$parameters = $notification->getSubjectParameters();
$notification->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
'serverAndVersion' => [
'type' => 'highlight',
'id' => $notification->getObjectType(),
@ -137,27 +141,28 @@ class Notifier implements INotifier {
]
]);
if ($this->isAdmin()) {
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
}
} else {
$appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
$appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
if ($this->isAdmin()) {
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
}
} else {
$appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
$appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
if (isset($this->appVersions[$notification->getObjectType()])) {
$this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
}
if (isset($this->appVersions[$notification->getObjectType()])) {
$this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
}
$notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
'app' => [
'type' => 'app',
'id' => $notification->getObjectType(),
'name' => $appName,
]
]);
$notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
'app' => [
'type' => 'app',
'id' => $notification->getObjectType(),
'name' => $appName,
]
]);
if ($this->isAdmin()) {
$notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
if ($this->isAdmin()) {
$notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
}
}
}

@ -3,7 +3,7 @@
"info": {
"title": "updatenotification",
"version": "0.0.1",
"description": "Displays update notifications for Nextcloud and provides the SSO for the updater.",
"description": "Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.",
"license": {
"name": "agpl"
}
@ -203,6 +203,144 @@
}
}
}
},
"/ocs/v2.php/apps/updatenotification/api/{apiVersion}/changelog/{appId}": {
"get": {
"operationId": "api-get-app-changelog-entry",
"summary": "Get changelog entry for an app",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "version",
"in": "query",
"description": "The version to search the changelog entry for (defaults to the latest installed)",
"schema": {
"type": "string",
"nullable": true
}
},
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v1"
],
"default": "v1"
}
},
{
"name": "appId",
"in": "path",
"description": "App to search changelog entry for",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Changelog entry returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"appName",
"content",
"version"
],
"properties": {
"appName": {
"type": "string"
},
"content": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"404": {
"description": "No changelog found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"tags": []

@ -0,0 +1,96 @@
<template>
<NcDialog content-classes="app-changelog-dialog"
:buttons="dialogButtons"
:name="t('updatenotification', 'What\'s new in {app} {version}', { app: appName, version: appVersion })"
:open="open && markdown !== undefined"
size="normal"
@update:open="$emit('update:open', $event)">
<Markdown class="app-changelog-dialog__text" :markdown="markdown" :min-heading-level="3" />
</NcDialog>
</template>
<script setup lang="ts">
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { ref, watchEffect } from 'vue'
import axios from '@nextcloud/axios'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import Markdown from './Markdown.vue'
const props = withDefaults(
defineProps<{
appId: string
version?: string
open?: boolean
}>(),
// Default values
{
open: true,
version: undefined,
},
)
const emit = defineEmits<{
/**
* Event that is called when the "Get started"-button is pressed
*/
(e: 'dismiss'): void
(e: 'update:open', v: boolean): void
}>()
const dialogButtons = [
{
label: t('updatenotification', 'Give feedback'),
callback: () => {
window.open(`https://apps.nextcloud.com/apps/${props.appId}#comments`, '_blank', 'noreferrer noopener')
},
},
{
label: t('updatenotification', 'Get started'),
type: 'primary',
callback: () => {
emit('dismiss')
emit('update:open', false)
},
},
]
const appName = ref(props.appId)
const appVersion = ref(props.version ?? '')
const markdown = ref<string>('')
watchEffect(() => {
const url = props.version
? generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}?version={version}', { version: props.version, app: props.appId })
: generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}', { version: props.version, app: props.appId })
axios.get(url)
.then(({ data }) => {
appName.value = data.ocs.data.appName
appVersion.value = data.ocs.data.version
markdown.value = data.ocs.data.content
})
.catch((error) => {
if (error?.response?.status === 404) {
appName.value = props.appId
markdown.value = t('updatenotification', 'No changelog available')
} else {
console.error('Failed to load changelog entry', error)
emit('update:open', false)
}
})
})
</script>
<style scoped lang="scss">
:deep(.app-changelog-dialog) {
min-height: 50vh !important;
}
.app-changelog-dialog__text {
padding-inline: 14px;
}
</style>

@ -0,0 +1,56 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown" v-html="html" />
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { useMarkdown } from '../composables/useMarkdown'
const props = withDefaults(
defineProps<{
markdown: string
minHeadingLevel?: 1|2|3|4|5|6
}>(),
{
minHeadingLevel: 2,
},
)
const { html } = useMarkdown(toRef(props, 'markdown'), toRef(props, 'minHeadingLevel'))
</script>
<style scoped lang="scss">
.markdown {
:deep {
ul {
list-style: disc;
padding-inline-start: 20px;
}
h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.5;
margin-top: 24px;
margin-bottom: 12px;
color: var(--color-main-text);
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 17px;
}
h6 {
font-size: var(--default-font-size);
}
}
}
</style>

@ -0,0 +1,62 @@
import type { Ref } from 'vue'
import { marked } from 'marked'
import { computed } from 'vue'
import dompurify from 'dompurify'
export const useMarkdown = (text: Ref<string|undefined|null>, minHeadingLevel: Ref<number|undefined>) => {
const minHeading = computed(() => Math.min(Math.max(minHeadingLevel.value ?? 1, 1), 6))
const renderer = new marked.Renderer()
renderer.link = function(href, title, text) {
let out = `<a href="${href}" rel="noreferrer noopener" target="_blank"`
if (title) {
out += ' title="' + title + '"'
}
out += '>' + text + '</a>'
return out
}
renderer.image = function(href, title, text) {
if (text) {
return text
}
return title ?? ''
}
renderer.heading = (text: string, level: number) => {
const headingLevel = Math.max(minHeading.value, level)
return `<h${headingLevel}>${text}</h${headingLevel}>`
}
const html = computed(() => dompurify.sanitize(
marked((text.value ?? '').trim(), {
renderer,
gfm: false,
breaks: false,
pedantic: false,
}),
{
SAFE_FOR_JQUERY: true,
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'strong',
'p',
'a',
'ul',
'ol',
'li',
'em',
'del',
'blockquote',
],
},
))
return { html }
}

@ -0,0 +1,75 @@
import { subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import Vue, { defineAsyncComponent } from 'vue'
import axios from '@nextcloud/axios'
const navigationEntries = loadState('core', 'apps', {})
const DialogVue = defineAsyncComponent(() => import('./components/AppChangelogDialog.vue'))
/**
* Show the app changelog dialog
*
* @param appId The app to show the changelog for
* @param version Optional version to show
*/
function showDialog(appId: string, version?: string) {
const element = document.createElement('div')
document.body.appendChild(element)
return new Promise((resolve) => {
let dismissed = false
const dialog = new Vue({
el: element,
render: (h) => h(DialogVue, {
props: {
appId,
version,
},
on: {
dismiss: () => { dismissed = true },
'update:open': (open: boolean) => {
if (!open) {
dialog.$destroy?.()
resolve(dismissed)
if (dismissed && appId in navigationEntries) {
window.location = navigationEntries[appId].href
}
}
},
},
}),
})
})
}
interface INotificationActionEvent {
cancelAction: boolean
notification: Readonly<{
notificationId: number
objectId: string
objectType: string
}>
action: Readonly<{
url: string
type: 'WEB'|'GET'|'POST'|'DELETE'
}>,
}
subscribe('notifications:action:execute', (event: INotificationActionEvent) => {
if (event.notification.objectType === 'app_updated') {
event.cancelAction = true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, app, version, __] = event.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/) ?? []
showDialog((app as string|undefined) || (event.notification.objectId as string), version)
.then((dismissed) => {
if (dismissed) {
axios.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: event.notification.notificationId }))
}
})
}
})

@ -0,0 +1,8 @@
import Vue from 'vue'
import App from './views/App.vue'
export default new Vue({
name: 'ViewChangelogPage',
render: (h) => h(App),
el: '#content',
})

@ -0,0 +1,39 @@
<template>
<NcContent app-name="updatenotification">
<NcAppContent :page-heading="t('updatenotification', 'Changelog for app {app}', { app: appName })">
<div class="changelog__wrapper">
<h2 class="changelog__heading">
{{ t('updatenotification', 'What\'s new in {app} version {version}', { app: appName, version: appVersion }) }}
</h2>
<Markdown :markdown="markdown" :min-heading-level="3" />
</div>
</NcAppContent>
</NcContent>
</template>
<script setup lang="ts">
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
import Markdown from '../components/Markdown.vue'
const {
appName,
appVersion,
text: markdown,
} = loadState<{ appName: string, appVersion: string, text: string }>('updatenotification', 'changelog')
</script>
<style scoped>
.changelog__wrapper {
max-width: max(50vw,700px);
margin-inline: auto;
}
.changelog__heading {
font-size: 30px;
margin-block: var(--app-navigation-padding, 8px) 1em;
}
</style>

@ -0,0 +1,4 @@
<?php
/**
* Empty as Vue will take over
*/

@ -27,24 +27,29 @@ declare(strict_types=1);
*/
namespace OCA\UpdateNotification\Tests;
use OCA\UpdateNotification\ResetTokenBackgroundJob;
use OCA\UpdateNotification\BackgroundJob\ResetToken as BackgroundJobResetToken;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IAppConfig;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class ResetTokenBackgroundJobTest extends TestCase {
/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
private $config;
/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
private $timeFactory;
/** @var ResetTokenBackgroundJob */
private $resetTokenBackgroundJob;
class ResetTokenTest extends TestCase {
private IConfig|MockObject $config;
private IAppConfig|MockObject $appConfig;
private ITimeFactory|MockObject $timeFactory;
private BackgroundJobResetToken $resetTokenBackgroundJob;
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->config = $this->createMock(IConfig::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->resetTokenBackgroundJob = new ResetTokenBackgroundJob($this->config, $this->timeFactory);
$this->resetTokenBackgroundJob = new BackgroundJobResetToken(
$this->timeFactory,
$this->config,
$this->appConfig,
);
}
public function testRunWithNotExpiredToken() {

@ -25,12 +25,13 @@ declare(strict_types=1);
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\UpdateNotification\Tests\Notification;
namespace OCA\UpdateNotification\Tests\BackgroundJob;
use OC\Installer;
use OC\Updater\VersionCheck;
use OCA\UpdateNotification\Notification\BackgroundJob;
use OCA\UpdateNotification\BackgroundJob\UpdateAvailableNotifications;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\IGroup;
@ -41,26 +42,21 @@ use OCP\Notification\INotification;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class BackgroundJobTest extends TestCase {
/** @var IConfig|MockObject */
protected $config;
/** @var IManager|MockObject */
protected $notificationManager;
/** @var IGroupManager|MockObject */
protected $groupManager;
/** @var IAppManager|MockObject */
protected $appManager;
/** @var ITimeFactory|MockObject */
protected $timeFactory;
/** @var Installer|MockObject */
protected $installer;
/** @var VersionCheck|MockObject */
protected $versionCheck;
class UpdateAvailableNotificationsTest extends TestCase {
private IConfig|MockObject $config;
private IManager|MockObject $notificationManager;
private IGroupManager|MockObject $groupManager;
private IAppManager|MockObject $appManager;
private IAppConfig|MockObject $appConfig;
private ITimeFactory|MockObject $timeFactory;
private Installer|MockObject $installer;
private VersionCheck|MockObject $versionCheck;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->notificationManager = $this->createMock(IManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->appManager = $this->createMock(IAppManager::class);
@ -71,13 +67,14 @@ class BackgroundJobTest extends TestCase {
/**
* @param array $methods
* @return BackgroundJob|MockObject
* @return UpdateAvailableNotifications|MockObject
*/
protected function getJob(array $methods = []) {
if (empty($methods)) {
return new BackgroundJob(
return new UpdateAvailableNotifications(
$this->timeFactory,
$this->config,
$this->appConfig,
$this->notificationManager,
$this->groupManager,
$this->appManager,
@ -86,17 +83,18 @@ class BackgroundJobTest extends TestCase {
);
}
{
return $this->getMockBuilder(BackgroundJob::class)
return $this->getMockBuilder(UpdateAvailableNotifications::class)
->setConstructorArgs([
$this->timeFactory,
$this->config,
$this->appConfig,
$this->notificationManager,
$this->groupManager,
$this->appManager,
$this->installer,
$this->versionCheck,
])
->setMethods($methods)
->onlyMethods($methods)
->getMock();
}
}

@ -27,32 +27,29 @@ declare(strict_types=1);
*/
namespace OCA\UpdateNotification\Tests\Controller;
use OCA\UpdateNotification\BackgroundJob\ResetToken;
use OCA\UpdateNotification\Controller\AdminController;
use OCA\UpdateNotification\ResetTokenBackgroundJob;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\Security\ISecureRandom;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class AdminControllerTest extends TestCase {
/** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */
private $request;
/** @var IJobList|\PHPUnit\Framework\MockObject\MockObject */
private $jobList;
/** @var ISecureRandom|\PHPUnit\Framework\MockObject\MockObject */
private $secureRandom;
/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
private $config;
/** @var AdminController */
private $adminController;
/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
private $timeFactory;
/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
private $l10n;
private IRequest|MockObject $request;
private IJobList|MockObject $jobList;
private ISecureRandom|MockObject $secureRandom;
private IConfig|MockObject $config;
private ITimeFactory|MockObject $timeFactory;
private IL10N|MockObject $l10n;
private IAppConfig|MockObject $appConfig;
private AdminController $adminController;
protected function setUp(): void {
parent::setUp();
@ -61,6 +58,7 @@ class AdminControllerTest extends TestCase {
$this->jobList = $this->createMock(IJobList::class);
$this->secureRandom = $this->createMock(ISecureRandom::class);
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->l10n = $this->createMock(IL10N::class);
@ -70,6 +68,7 @@ class AdminControllerTest extends TestCase {
$this->jobList,
$this->secureRandom,
$this->config,
$this->appConfig,
$this->timeFactory,
$this->l10n
);
@ -79,7 +78,7 @@ class AdminControllerTest extends TestCase {
$this->jobList
->expects($this->once())
->method('add')
->with(ResetTokenBackgroundJob::class);
->with(ResetToken::class);
$this->secureRandom
->expects($this->once())
->method('generate')

@ -89,7 +89,7 @@ class NotifierTest extends TestCase {
$this->userSession,
$this->groupManager,
])
->setMethods($methods)
->onlyMethods($methods)
->getMock();
}
}

2
dist/6794-6794.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,2 @@
(()=>{"use strict";var e,t,r,o={38248:(e,t,r)=>{var o=r(61338),n=r(38613),i=r(99498),a=r(85471),c=r(26287);const l=(0,n.C)("core","apps",{}),d=(0,a.$V)((()=>Promise.all([r.e(4208),r.e(6794)]).then(r.bind(r,6794))));(0,o.B1)("notifications:action:execute",(e=>{if("app_updated"===e.notification.objectType){var t;e.cancelAction=!0;const[r,o,n,s]=null!==(t=e.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/))&&void 0!==t?t:[];(function(e,t){const r=document.createElement("div");return document.body.appendChild(r),new Promise((o=>{let n=!1;const i=new a.Ay({el:r,render:r=>r(d,{props:{appId:e,version:t},on:{dismiss:()=>{n=!0},"update:open":t=>{var r;t||(null===(r=i.$destroy)||void 0===r||r.call(i),o(n),n&&e in l&&(window.location=l[e].href))}}})})}))})(o||e.notification.objectId,n).then((t=>{t&&c.A.delete((0,i.KT)("apps/notifications/api/v2/notifications/{id}",{id:e.notification.notificationId}))}))}}))}},n={};function i(e){var t=n[e];if(void 0!==t)return t.exports;var r=n[e]={id:e,loaded:!1,exports:{}};return o[e].call(r.exports,r,r.exports,i),r.loaded=!0,r.exports}i.m=o,e=[],i.O=(t,r,o,n)=>{if(!r){var a=1/0;for(s=0;s<e.length;s++){r=e[s][0],o=e[s][1],n=e[s][2];for(var c=!0,l=0;l<r.length;l++)(!1&n||a>=n)&&Object.keys(i.O).every((e=>i.O[e](r[l])))?r.splice(l--,1):(c=!1,n<a&&(a=n));if(c){e.splice(s--,1);var d=o();void 0!==d&&(t=d)}}return t}n=n||0;for(var s=e.length;s>0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[r,o,n]},i.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return i.d(t,{a:t}),t},i.d=(e,t)=>{for(var r in t)i.o(t,r)&&!i.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce(((t,r)=>(i.f[r](e,t),t)),[])),i.u=e=>e+"-"+e+".js?v=0dcea5eda96c3f05a6f1",i.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),t={},r="nextcloud:",i.l=(e,o,n,a)=>{if(t[e])t[e].push(o);else{var c,l;if(void 0!==n)for(var d=document.getElementsByTagName("script"),s=0;s<d.length;s++){var u=d[s];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==r+n){c=u;break}}c||(l=!0,(c=document.createElement("script")).charset="utf-8",c.timeout=120,i.nc&&c.setAttribute("nonce",i.nc),c.setAttribute("data-webpack",r+n),c.src=e),t[e]=[o];var p=(r,o)=>{c.onerror=c.onload=null,clearTimeout(f);var n=t[e];if(delete t[e],c.parentNode&&c.parentNode.removeChild(c),n&&n.forEach((e=>e(o))),r)return r(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=p.bind(null,c.onerror),c.onload=p.bind(null,c.onload),l&&document.head.appendChild(c)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),i.j=1864,(()=>{var e;i.g.importScripts&&(e=i.g.location+"");var t=i.g.document;if(!e&&t&&(t.currentScript&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var o=r.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=r[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),i.p=e})(),(()=>{i.b=document.baseURI||self.location.href;var e={1864:0};i.f.j=(t,r)=>{var o=i.o(e,t)?e[t]:void 0;if(0!==o)if(o)r.push(o[2]);else{var n=new Promise(((r,n)=>o=e[t]=[r,n]));r.push(o[2]=n);var a=i.p+i.u(t),c=new Error;i.l(a,(r=>{if(i.o(e,t)&&(0!==(o=e[t])&&(e[t]=void 0),o)){var n=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;c.message="Loading chunk "+t+" failed.\n("+n+": "+a+")",c.name="ChunkLoadError",c.type=n,c.request=a,o[1](c)}}),"chunk-"+t,t)}},i.O.j=t=>0===e[t];var t=(t,r)=>{var o,n,a=r[0],c=r[1],l=r[2],d=0;if(a.some((t=>0!==e[t]))){for(o in c)i.o(c,o)&&(i.m[o]=c[o]);if(l)var s=l(i)}for(t&&t(r);d<a.length;d++)n=a[d],i.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return i.O(s)},r=self.webpackChunknextcloud=self.webpackChunknextcloud||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})(),i.nc=void 0;var a=i.O(void 0,[4208],(()=>i(38248)));a=i.O(a)})();
//# sourceMappingURL=updatenotification-init.js.map?v=7318823919577215060f

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -56,6 +56,7 @@ use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Settings\IManager as ISettingsManager;
@ -104,9 +105,24 @@ class AppManager implements IAppManager {
private ICacheFactory $memCacheFactory,
private IEventDispatcher $dispatcher,
private LoggerInterface $logger,
private IURLGenerator $urlGenerator,
) {
}
public function getAppIcon(string $appId): ?string {
$possibleIcons = [$appId . '.svg', 'app.svg', $appId . '-dark.svg', 'app-dark.svg'];
$icon = null;
foreach ($possibleIcons as $iconName) {
try {
$icon = $this->urlGenerator->imagePath($appId, $iconName);
break;
} catch (\RuntimeException $e) {
// ignore
}
}
return $icon;
}
/**
* @return string[] $appId => $enabled
*/

@ -372,15 +372,17 @@ class NavigationManager implements INavigationManager {
$order = $nav['order'] ?? 100;
$type = $nav['type'];
$route = !empty($nav['route']) ? $this->urlGenerator->linkToRoute($nav['route']) : '';
$icon = $nav['icon'] ?? 'app.svg';
foreach ([$icon, "$app.svg"] as $i) {
$icon = $nav['icon'] ?? null;
if ($icon !== null) {
try {
$icon = $this->urlGenerator->imagePath($app, $i);
break;
$icon = $this->urlGenerator->imagePath($app, $icon);
} catch (\RuntimeException $ex) {
// no icon? - ignore it then
// ignore
}
}
if ($icon === null) {
$icon = $this->appManager->getAppIcon($app);
}
if ($icon === null) {
$icon = $this->urlGenerator->imagePath('core', 'default-app-icon');
}

@ -893,7 +893,8 @@ class Server extends ServerContainer implements IServerContainer {
$c->get(IGroupManager::class),
$c->get(ICacheFactory::class),
$c->get(IEventDispatcher::class),
$c->get(LoggerInterface::class)
$c->get(LoggerInterface::class),
$c->get(IURLGenerator::class),
);
});
/** @deprecated 19.0.0 */

@ -61,6 +61,15 @@ interface IAppManager {
*/
public function getAppVersion(string $appId, bool $useCache = true): string;
/**
* Returns the app icon or null if none is found
*
* @param string $appId
* @return string|null
* @since 29.0.0
*/
public function getAppIcon(string $appId): string|null;
/**
* Check if an app is enabled for user
*

@ -23,6 +23,7 @@ use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
@ -98,6 +99,8 @@ class AppManagerTest extends TestCase {
/** @var LoggerInterface|MockObject */
protected $logger;
protected IURLGenerator|MockObject $urlGenerator;
/** @var IAppManager */
protected $manager;
@ -112,6 +115,7 @@ class AppManagerTest extends TestCase {
$this->cache = $this->createMock(ICache::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->cacheFactory->expects($this->any())
->method('createDistributed')
->with('settings')
@ -123,10 +127,74 @@ class AppManagerTest extends TestCase {
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger
$this->logger,
$this->urlGenerator,
);
}
/**
* @dataProvider dataGetAppIcon
*/
public function testGetAppIcon($callback, string|null $expected) {
$this->urlGenerator->expects($this->atLeastOnce())
->method('imagePath')
->willReturnCallback($callback);
$this->assertEquals($expected, $this->manager->getAppIcon('test'));
}
public function dataGetAppIcon(): array {
$nothing = function ($appId) {
$this->assertEquals('test', $appId);
throw new \RuntimeException();
};
$createCallback = function ($workingIcons) {
return function ($appId, $icon) use ($workingIcons) {
$this->assertEquals('test', $appId);
if (in_array($icon, $workingIcons)) {
return '/path/' . $icon;
}
throw new \RuntimeException();
};
};
return [
'does not find anything' => [
$nothing,
null,
],
'only app.svg' => [
$createCallback(['app.svg']),
'/path/app.svg',
],
'only app-dark.svg' => [
$createCallback(['app-dark.svg']),
'/path/app-dark.svg',
],
'only appname -dark.svg' => [
$createCallback(['test-dark.svg']),
'/path/test-dark.svg',
],
'only appname.svg' => [
$createCallback(['test.svg']),
'/path/test.svg',
],
'priotize custom over default' => [
$createCallback(['app.svg', 'test.svg']),
'/path/test.svg',
],
'priotize default over dark' => [
$createCallback(['test-dark.svg', 'app-dark.svg', 'app.svg']),
'/path/app.svg',
],
'priotize custom over default' => [
$createCallback(['test.svg', 'test-dark.svg', 'app-dark.svg']),
'/path/test.svg',
],
];
}
public function testEnableApp() {
// making sure "files_trashbin" is disabled
if ($this->manager->isEnabledForUser('files_trashbin')) {
@ -170,9 +238,16 @@ class AppManagerTest extends TestCase {
/** @var AppManager|MockObject $manager */
$manager = $this->getMockBuilder(AppManager::class)
->setConstructorArgs([
$this->userSession, $this->config, $this->appConfig, $this->groupManager, $this->cacheFactory, $this->eventDispatcher, $this->logger
$this->userSession,
$this->config,
$this->appConfig,
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger,
$this->urlGenerator,
])
->setMethods([
->onlyMethods([
'getAppPath',
])
->getMock();
@ -218,9 +293,16 @@ class AppManagerTest extends TestCase {
/** @var AppManager|MockObject $manager */
$manager = $this->getMockBuilder(AppManager::class)
->setConstructorArgs([
$this->userSession, $this->config, $this->appConfig, $this->groupManager, $this->cacheFactory, $this->eventDispatcher, $this->logger
$this->userSession,
$this->config,
$this->appConfig,
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger,
$this->urlGenerator,
])
->setMethods([
->onlyMethods([
'getAppPath',
'getAppInfo',
])
@ -274,9 +356,16 @@ class AppManagerTest extends TestCase {
/** @var AppManager|MockObject $manager */
$manager = $this->getMockBuilder(AppManager::class)
->setConstructorArgs([
$this->userSession, $this->config, $this->appConfig, $this->groupManager, $this->cacheFactory, $this->eventDispatcher, $this->logger
$this->userSession,
$this->config,
$this->appConfig,
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger,
$this->urlGenerator,
])
->setMethods([
->onlyMethods([
'getAppPath',
'getAppInfo',
])
@ -470,8 +559,17 @@ class AppManagerTest extends TestCase {
public function testGetAppsNeedingUpgrade() {
/** @var AppManager|MockObject $manager */
$manager = $this->getMockBuilder(AppManager::class)
->setConstructorArgs([$this->userSession, $this->config, $this->appConfig, $this->groupManager, $this->cacheFactory, $this->eventDispatcher, $this->logger])
->setMethods(['getAppInfo'])
->setConstructorArgs([
$this->userSession,
$this->config,
$this->appConfig,
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger,
$this->urlGenerator,
])
->onlyMethods(['getAppInfo'])
->getMock();
$appInfos = [
@ -521,8 +619,17 @@ class AppManagerTest extends TestCase {
public function testGetIncompatibleApps() {
/** @var AppManager|MockObject $manager */
$manager = $this->getMockBuilder(AppManager::class)
->setConstructorArgs([$this->userSession, $this->config, $this->appConfig, $this->groupManager, $this->cacheFactory, $this->eventDispatcher, $this->logger])
->setMethods(['getAppInfo'])
->setConstructorArgs([
$this->userSession,
$this->config,
$this->appConfig,
$this->groupManager,
$this->cacheFactory,
$this->eventDispatcher,
$this->logger,
$this->urlGenerator,
])
->onlyMethods(['getAppInfo'])
->getMock();
$appInfos = [

@ -14,6 +14,8 @@ use OC\App\InfoParser;
use OC\AppConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IAppConfig;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
/**
@ -537,6 +539,7 @@ class AppTest extends \Test\TestCase {
private function setupAppConfigMock() {
/** @var AppConfig|MockObject */
$appConfig = $this->getMockBuilder(AppConfig::class)
->setMethods(['getValues'])
->setConstructorArgs([\OC::$server->getDatabaseConnection()])
@ -561,7 +564,8 @@ class AppTest extends \Test\TestCase {
\OC::$server->getGroupManager(),
\OC::$server->getMemCacheFactory(),
\OC::$server->get(IEventDispatcher::class),
\OC::$server->get(LoggerInterface::class)
\OC::$server->get(LoggerInterface::class),
\OC::$server->get(IURLGenerator::class),
));
}

@ -224,19 +224,19 @@ class NavigationManagerTest extends TestCase {
->method('isEnabledForUser')
->with('theming')
->willReturn(true);
$this->appManager->expects($this->once())->method('getAppInfo')->with('test')->willReturn($navigation);
/*
$this->appManager->expects($this->once())
->method('getAppInfo')
->with('test')
->willReturn($navigation);
$this->urlGenerator->expects($this->any())
->method('imagePath')
->willReturnCallback(function ($appName, $file) {
return "/apps/$appName/img/$file";
});
$this->appManager->expects($this->any())
->method('getAppInfo')
->will($this->returnValueMap([
['test', null, null, $navigation],
['theming', null, null, null],
]));
*/
->method('getAppIcon')
->willReturnCallback(fn (string $appName) => "/apps/$appName/img/app.svg");
$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";
});
$this->urlGenerator->expects($this->any())->method('linkToRoute')->willReturnCallback(function ($route) {
if ($route === 'core.login.logout') {
return 'https://example.com/logout';
@ -534,6 +534,7 @@ class NavigationManagerTest extends TestCase {
->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->l10nFac->expects($this->any())->method('get')->willReturn($l);
$this->urlGenerator->expects($this->any())->method('imagePath')->willReturnCallback(function ($appName, $file) {
return "/apps/$appName/img/$file";

@ -114,7 +114,9 @@ module.exports = {
settings: path.join(__dirname, 'apps/twofactor_backupcodes/src', 'settings.js'),
},
updatenotification: {
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'init.js'),
init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'updatenotification.js'),
},
user_status: {
menu: path.join(__dirname, 'apps/user_status/src', 'menu.js'),

Loading…
Cancel
Save