From cfc4905e4a09205377dd83537ad123b23ce215ce Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 2 Dec 2024 22:49:32 +0100 Subject: [PATCH] feat(call): Direct endpoint to check if call notification should be dismissed Signed-off-by: Joas Schilling --- appinfo/routes/routesCallController.php | 2 + docs/capabilities.md | 3 + lib/Capabilities.php | 2 + lib/Controller/CallNotificationController.php | 65 +++++++ lib/Service/ParticipantService.php | 55 ++++++ openapi-full.json | 174 ++++++++++++++++++ openapi.json | 174 ++++++++++++++++++ src/types/openapi/openapi-full.ts | 94 ++++++++++ src/types/openapi/openapi.ts | 94 ++++++++++ .../features/bootstrap/FeatureContext.php | 15 ++ .../features/callapi/notifications.feature | 8 +- .../features/federation/call.feature | 11 ++ 12 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/CallNotificationController.php diff --git a/appinfo/routes/routesCallController.php b/appinfo/routes/routesCallController.php index 15c9062c322..2a878c77153 100644 --- a/appinfo/routes/routesCallController.php +++ b/appinfo/routes/routesCallController.php @@ -15,6 +15,8 @@ 'ocs' => [ /** @see \OCA\Talk\Controller\CallController::getPeersForCall() */ ['name' => 'Call#getPeersForCall', 'url' => '/api/{apiVersion}/call/{token}', 'verb' => 'GET', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\CallNotificationController::state() */ + ['name' => 'CallNotification#state', 'url' => '/api/{apiVersion}/call/{token}/notification-state', 'verb' => 'GET', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\CallController::downloadParticipantsForCall() */ ['name' => 'Call#downloadParticipantsForCall', 'url' => '/api/{apiVersion}/call/{token}/download', 'verb' => 'GET', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\CallController::joinCall() */ diff --git a/docs/capabilities.md b/docs/capabilities.md index 5ee8adda207..ab67bebdaf8 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -166,3 +166,6 @@ * `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation * `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran. * `config => call => blur-virtual-background` (local) - Boolean, whether blur background is set by default when joining a conversation + +## 20.1.1 +* `call-notification-state-api` (local) - Whether the endpoints exists for checking if a call notification should be dismissed diff --git a/lib/Capabilities.php b/lib/Capabilities.php index e131f90cf1c..ec47852e2b3 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -109,6 +109,7 @@ class Capabilities implements IPublicCapability { 'talk-polls-drafts', 'download-call-participants', 'email-csv-import', + 'call-notification-state-api', ]; public const CONDITIONAL_FEATURES = [ @@ -129,6 +130,7 @@ class Capabilities implements IPublicCapability { 'note-to-self', 'archived-conversations-v2', 'chat-summary-api', + 'call-notification-state-api', ]; public const LOCAL_CONFIGS = [ diff --git a/lib/Controller/CallNotificationController.php b/lib/Controller/CallNotificationController.php new file mode 100644 index 00000000000..31f1a881541 --- /dev/null +++ b/lib/Controller/CallNotificationController.php @@ -0,0 +1,65 @@ + + * + * 200: Notification should be kept alive + * 201: Dismiss call notification and show "Missed call"-notification instead + * 403: Not logged in, try again with auth data sent + * 404: Dismiss call notification + */ + #[NoAdminRequired] + #[OpenAPI(tags: ['call'])] + public function state(string $token): DataResponse { + if ($this->userId === null) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + $status = match($this->participantService->checkIfUserIsMissingCall($token, $this->userId)) { + self::CASE_PARTICIPANT_JOINED, + self::CASE_ROOM_NOT_FOUND => Http::STATUS_NOT_FOUND, + self::CASE_MISSED_CALL => Http::STATUS_CREATED, + self::CASE_STILL_CURRENT => Http::STATUS_OK, + }; + + return new DataResponse(null, $status); + } +} diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index 73388147a54..838c95e14f6 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -14,6 +14,7 @@ use OCA\Talk\CachePrefix; use OCA\Talk\Chat\ChatManager; use OCA\Talk\Config; +use OCA\Talk\Controller\CallNotificationController; use OCA\Talk\Events\AAttendeeRemovedEvent; use OCA\Talk\Events\AParticipantModifiedEvent; use OCA\Talk\Events\AttendeeRemovedEvent; @@ -1599,6 +1600,60 @@ public function getParticipantsInCall(Room $room, int $maxAge = 0): array { return $this->getParticipantsFromQuery($query, $room); } + /** + * Do not try to modernize this into using the Room, Participant or other objects. + * This function is called by {@see CallNotificationController::state} + * and mobile as well as desktop clients are basically ddos-ing it, to check + * if the call notification / call screen should be removed. + * @return CallNotificationController::CASE_* + */ + public function checkIfUserIsMissingCall(string $token, string $userId): int { + $query = $this->connection->getQueryBuilder(); + $query->select('r.active_since', 'a.last_joined_call', 's.in_call') + ->from('talk_rooms', 'r') + ->innerJoin( + 'r', 'talk_attendees', 'a', + $query->expr()->eq('r.id', 'a.room_id') + ) + ->leftJoin( + 'a', 'talk_sessions', 's', + $query->expr()->andX( + $query->expr()->eq('s.attendee_id', 'a.id'), + $query->expr()->neq('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + ) + ) + ->where($query->expr()->eq('r.token', $query->createNamedParameter($token))) + ->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS))) + ->andWhere($query->expr()->eq('a.actor_id', $query->createNamedParameter($userId))); + + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + return CallNotificationController::CASE_ROOM_NOT_FOUND; + } + + if ($row['active_since'] === null) { + return CallNotificationController::CASE_MISSED_CALL; + } + + try { + $activeSince = new \DateTime($row['active_since']); + } catch (\Throwable) { + return CallNotificationController::CASE_MISSED_CALL; + } + + if ($row['in_call'] !== null) { + return CallNotificationController::CASE_PARTICIPANT_JOINED; + } + + if ($activeSince->getTimestamp() >= $row['last_joined_call']) { + return CallNotificationController::CASE_STILL_CURRENT; + } + return CallNotificationController::CASE_PARTICIPANT_JOINED; + } + /** * @return Participant[] */ diff --git a/openapi-full.json b/openapi-full.json index c66cefb0c9f..c026fb14c2c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -4561,6 +4561,180 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/notification-state": { + "get": { + "operationId": "call_notification-state", + "summary": "Check the expected state of a call notification", + "description": "Required capability: `call-notification-state-api`", + "tags": [ + "call" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "description": "Conversation token to check", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "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": "Notification should be kept alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "201": { + "description": "Dismiss call notification and show \"Missed call\"-notification instead", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not logged in, try again with auth data sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "Dismiss call notification", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/download": { "get": { "operationId": "call-download-participants-for-call", diff --git a/openapi.json b/openapi.json index 10c7d8a8382..fd205e55cd0 100644 --- a/openapi.json +++ b/openapi.json @@ -4448,6 +4448,180 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/notification-state": { + "get": { + "operationId": "call_notification-state", + "summary": "Check the expected state of a call notification", + "description": "Required capability: `call-notification-state-api`", + "tags": [ + "call" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "description": "Conversation token to check", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "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": "Notification should be kept alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "201": { + "description": "Dismiss call notification and show \"Missed call\"-notification instead", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not logged in, try again with auth data sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "Dismiss call notification", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/download": { "get": { "operationId": "call-download-participants-for-call", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index b7f4b7b875b..e54cdfc57cb 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -261,6 +261,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/notification-state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check the expected state of a call notification + * @description Required capability: `call-notification-state-api` + */ + get: operations["call_notification-state"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/download": { parameters: { query?: never; @@ -3515,6 +3535,80 @@ export interface operations { }; }; }; + "call_notification-state": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description Conversation token to check */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Notification should be kept alive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Dismiss call notification and show "Missed call"-notification instead */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Not logged in, try again with auth data sent */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Dismiss call notification */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "call-download-participants-for-call": { parameters: { query?: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 0567e857d53..9a95781912d 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -261,6 +261,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/notification-state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check the expected state of a call notification + * @description Required capability: `call-notification-state-api` + */ + get: operations["call_notification-state"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}/download": { parameters: { query?: never; @@ -2996,6 +3016,80 @@ export interface operations { }; }; }; + "call_notification-state": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description Conversation token to check */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Notification should be kept alive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Dismiss call notification and show "Missed call"-notification instead */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Not logged in, try again with auth data sent */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Dismiss call notification */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "call-download-participants-for-call": { parameters: { query?: { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 7073584eb68..a0519a2dd6e 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2124,6 +2124,21 @@ public function userJoinsCall(string $user, string $identifier, int $statusCode, } } + /** + * @Then /^user "([^"]*)" checks call notification for "([^"]*)" with (\d+) \((v4)\)$/ + * + * @param string $user + * @param string $identifier + * @param int $statusCode + * @param string $apiVersion + * @param TableNode|null $formData + */ + public function userChecksCallNotification(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier] . '/notification-state'); + $this->assertStatusCode($this->response, $statusCode); + } + /** * @Then /^user "([^"]*)" updates call flags in room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/ * diff --git a/tests/integration/features/callapi/notifications.feature b/tests/integration/features/callapi/notifications.feature index 27bb24069bc..577e7a997fe 100644 --- a/tests/integration/features/callapi/notifications.feature +++ b/tests/integration/features/callapi/notifications.feature @@ -11,11 +11,14 @@ Feature: callapi/notifications And user "participant1" adds user "participant2" to room "room" with 200 (v4) Given user "participant1" joins room "room" with 200 (v4) Given user "participant2" joins room "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 201 (v4) Given user "participant1" joins call "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 200 (v4) Then user "participant2" has the following notifications | app | object_type | object_id | subject | | spreed | call | room | A group call has started in room | Given user "participant2" joins call "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 404 (v4) Then user "participant2" has the following notifications | app | object_type | object_id | subject | @@ -26,7 +29,9 @@ Feature: callapi/notifications And user "participant1" adds user "participant2" to room "room" with 200 (v4) Given user "participant1" joins room "room" with 200 (v4) Given user "participant2" joins room "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 201 (v4) Given user "participant1" joins call "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 200 (v4) Then user "participant2" sees the following system messages in room "room" with 200 | room | actorType | actorId | systemMessage | message | silent | messageParameters | | room | users | participant1 | call_started | {actor} started a call | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | @@ -36,6 +41,7 @@ Feature: callapi/notifications | app | object_type | object_id | subject | | spreed | call | room | A group call has started in room | Given user "participant1" leaves call "room" with 200 (v4) + Then user "participant2" checks call notification for "room" with 201 (v4) Then user "participant2" has the following notifications | app | object_type | object_id | subject | | spreed | call | room | You missed a group call in room | @@ -76,7 +82,7 @@ Feature: callapi/notifications Then user "participant2" has the following notifications | app | object_type | object_id | subject | | spreed | call | room | A group call has started in room | - + Scenario: Calling an attendee that is in DND throws an error 'status' message with 400 When user "participant1" creates room "room" (v4) | roomType | 2 | diff --git a/tests/integration/features/federation/call.feature b/tests/integration/features/federation/call.feature index 4ba6f97ea31..9124870bee9 100644 --- a/tests/integration/features/federation/call.feature +++ b/tests/integration/features/federation/call.feature @@ -24,9 +24,13 @@ Feature: federation/call | LOCAL::room | room | 2 | LOCAL | room | And using server "LOCAL" And user "participant1" joins room "room" with 200 (v4) + And using server "REMOTE" + Then user "participant2" checks call notification for "LOCAL::room" with 201 (v4) + And using server "LOCAL" And user "participant1" joins call "room" with 200 (v4) | flags | 3 | And using server "REMOTE" + Then user "participant2" checks call notification for "LOCAL::room" with 200 (v4) And user "participant2" joins room "LOCAL::room" with 200 (v4) And user "participant2" is participant of room "LOCAL::room" (v4) | callFlag | @@ -35,8 +39,10 @@ Feature: federation/call | actorType | actorId | inCall | | federated_users | participant1@{$LOCAL_URL} | 3 | | users | participant2 | 0 | + Then user "participant2" checks call notification for "LOCAL::room" with 200 (v4) When user "participant2" joins call "LOCAL::room" with 200 (v4) | flags | 7 | + Then user "participant2" checks call notification for "LOCAL::room" with 404 (v4) Then using server "LOCAL" And user "participant1" is participant of room "room" (v4) | callFlag | @@ -254,11 +260,16 @@ Feature: federation/call | id | name | type | remoteServer | remoteToken | | LOCAL::room | room | 2 | LOCAL | room | And user "participant2" joins room "LOCAL::room" with 200 (v4) + Then user "participant2" checks call notification for "LOCAL::room" with 201 (v4) And using server "LOCAL" And user "participant1" joins room "room" with 200 (v4) And user "participant1" joins call "room" with 200 (v4) + And using server "REMOTE" + Then user "participant2" checks call notification for "LOCAL::room" with 200 (v4) + And using server "LOCAL" When user "participant1" leaves call "room" with 200 (v4) Then using server "REMOTE" + Then user "participant2" checks call notification for "LOCAL::room" with 201 (v4) And user "participant2" has the following notifications | app | object_type | object_id | subject | | spreed | call | LOCAL::room | You missed a group call in room |