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 9d4b20b8aa7..87802a453c7 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -170,3 +170,4 @@ ## 21 * `config => conversations => force-passwords` - Whether passwords are enforced for public rooms * `conversation-creation-password` - Whether the endpoints for creating public conversations or making a conversation public support setting a password +* `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 c35a8ea9ad8..b8817a629f2 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -110,6 +110,7 @@ class Capabilities implements IPublicCapability { 'download-call-participants', 'email-csv-import', 'conversation-creation-password', + 'call-notification-state-api', ]; public const CONDITIONAL_FEATURES = [ @@ -130,6 +131,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 3f8acf991b0..c4f4e4648ad 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; @@ -1601,6 +1602,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 197430ef8a1..eccc60c132b 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -4560,6 +4560,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 e1f0374d0d6..e00793473bf 100644 --- a/openapi.json +++ b/openapi.json @@ -4447,6 +4447,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 1933ca84df4..eafe8a13943 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; @@ -3517,6 +3537,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 c8528722afb..6a208fd0f90 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; @@ -2998,6 +3018,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 26db9d526fe..82107eadc16 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2128,6 +2128,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 |