// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "components/webrtc/media_stream_devices_controller.h"

#include <algorithm>
#include <memory>
#include <utility>
#include <vector>

#include "base/functional/bind.h"
#include "build/build_config.h"
#include "components/permissions/permission_util.h"
#include "components/permissions/permissions_client.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/permission_controller.h"
#include "content/public/browser/permission_descriptor_util.h"
#include "content/public/browser/permission_result.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
#include "services/network/public/cpp/is_potentially_trustworthy.h"
#include "third_party/blink/public/common/permissions/permission_utils.h"
#include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h"
#include "third_party/blink/public/mojom/permissions/permission_status.mojom-shared.h"

#if BUILDFLAG(IS_ANDROID)
#include "components/permissions/android/android_permission_util.h"
#include "ui/android/window_android.h"
#endif

using blink::MediaStreamDevices;

namespace webrtc {

namespace {

// Returns true if the given PermissionType is being requested in
// |request|.
bool PermissionIsRequested(blink::PermissionType permission,
                           const content::MediaStreamRequest& request) {
  if (permission == blink::PermissionType::AUDIO_CAPTURE)
    return request.audio_type ==
           blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE;

  if (permission == blink::PermissionType::VIDEO_CAPTURE)
    return request.video_type ==
           blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE;

  return false;
}

}  // namespace

// static
void MediaStreamDevicesController::RequestPermissions(
    const content::MediaStreamRequest& request,
    MediaStreamDeviceEnumerator* enumerator,
    ResultCallback callback) {
  content::RenderFrameHost* rfh = content::RenderFrameHost::FromID(
      request.render_process_id, request.render_frame_id);
  // The RFH may have been destroyed by the time the request is processed.
  if (!rfh) {
    std::move(callback).Run(blink::mojom::StreamDevicesSet(),
                            blink::mojom::MediaStreamRequestResult::
                                FAILED_DUE_TO_SHUTDOWN_NO_RFH_IN_CONTROLLER,
                            false, {}, {});
    return;
  }

  const GURL url = rfh->GetLastCommittedOrigin().GetURL();
  if (url.is_empty() || url != request.security_origin) {
    std::move(callback).Run(
        blink::mojom::StreamDevicesSet(),
        blink::mojom::MediaStreamRequestResult::INVALID_SECURITY_ORIGIN, false,
        {}, {});
    return;
  }

  content::WebContents* web_contents =
      content::WebContents::FromRenderFrameHost(rfh);
  std::unique_ptr<MediaStreamDevicesController> controller(
      new MediaStreamDevicesController(web_contents, enumerator, request,
                                       std::move(callback)));

  std::vector<blink::mojom::PermissionDescriptorPtr> permission_types;

  content::PermissionController* permission_controller =
      web_contents->GetBrowserContext()->GetPermissionController();

  std::vector<std::string> requested_audio_capture_device_ids;
  std::vector<std::string> requested_video_capture_device_ids;

  if (controller->ShouldRequestAudio()) {
    auto audio_descriptor = content::PermissionDescriptorUtil::
        CreatePermissionDescriptorForPermissionType(
            blink::PermissionType::AUDIO_CAPTURE);
    content::PermissionResult permission_result =
        permission_controller->GetPermissionResultForCurrentDocument(
            audio_descriptor, rfh);
    // Check if web contents has allowlisted capability to ignore the denied
    // status and denied reason.
    bool web_contents_has_allowlisted_permission =
        permissions::PermissionsClient::AllowEmbeddedPermissionPromptForSurface(
            web_contents) &&
        permissions::PermissionsClient::Get()
            ->IsPrivilegedInternalWebUIOrNewTabPage(
                web_contents, request.url_origin.GetURL(),
                /*already_overrode_requester=*/false);
    if (permission_result.status == blink::mojom::PermissionStatus::DENIED &&
        !web_contents_has_allowlisted_permission) {
      controller->denial_reason_ = blink::mojom::MediaStreamRequestResult::
          PERMISSION_DENIED_BY_CONTROLLER;
      // If `rfh` is a fenced frame, it will have no permission policy as well.
      controller->RunCallback(
          permission_result.source ==
              content::PermissionStatusSource::FEATURE_POLICY ||
          permission_result.source ==
              content::PermissionStatusSource::FENCED_FRAME);
      return;
    }

    permission_types.push_back(std::move(audio_descriptor));
    requested_audio_capture_device_ids = request.requested_audio_device_ids;
  }
  if (controller->ShouldRequestVideo()) {
    auto video_descriptor = content::PermissionDescriptorUtil::
        CreatePermissionDescriptorForPermissionType(
            blink::PermissionType::VIDEO_CAPTURE);
    content::PermissionResult permission_result =
        permission_controller->GetPermissionResultForCurrentDocument(
            video_descriptor, rfh);
    if (permission_result.status == blink::mojom::PermissionStatus::DENIED) {
      controller->denial_reason_ = blink::mojom::MediaStreamRequestResult::
          PERMISSION_DENIED_BY_CONTROLLER;
      // If `rfh` is a fenced frame, it will have no permission policy as well.
      controller->RunCallback(
          permission_result.source ==
              content::PermissionStatusSource::FEATURE_POLICY ||
          permission_result.source ==
              content::PermissionStatusSource::FENCED_FRAME);
      return;
    }

    permission_types.push_back(std::move(video_descriptor));
    requested_video_capture_device_ids = request.requested_video_device_ids;

    bool has_pan_tilt_zoom_camera = controller->HasAvailableDevices(
        blink::PermissionType::CAMERA_PAN_TILT_ZOOM,
        request.requested_video_device_ids);

    // Request CAMERA_PAN_TILT_ZOOM only if the website requested the
    // pan-tilt-zoom permission and there are suitable PTZ capable devices
    // available.
    if (request.request_pan_tilt_zoom_permission && has_pan_tilt_zoom_camera) {
      auto ptz_descriptor = content::PermissionDescriptorUtil::
          CreatePermissionDescriptorForPermissionType(
              blink::PermissionType::CAMERA_PAN_TILT_ZOOM);
      permission_result =
          permission_controller->GetPermissionResultForCurrentDocument(
              ptz_descriptor, rfh);
      if (permission_result.status == blink::mojom::PermissionStatus::DENIED) {
        controller->denial_reason_ = blink::mojom::MediaStreamRequestResult::
            PERMISSION_DENIED_BY_CONTROLLER;
        controller->RunCallback(/*blocked_by_permissions_policy=*/false);
        return;
      }

      permission_types.push_back(std::move(ptz_descriptor));
    }
  }

  content::PermissionRequestDescription permission_request_description{
      std::move(permission_types), request.user_gesture,
      request.security_origin};
  permission_request_description.requested_audio_capture_device_ids =
      requested_audio_capture_device_ids;
  permission_request_description.requested_video_capture_device_ids =
      requested_video_capture_device_ids;

  // It is OK to ignore `request.security_origin` because it will be calculated
  // from `render_frame_host` and we always ignore `requesting_origin` for
  // `AUDIO_CAPTURE` and `VIDEO_CAPTURE`.
  // `render_frame_host->GetMainFrame()->GetLastCommittedOrigin()` will be used
  // instead.
  rfh->GetBrowserContext()
      ->GetPermissionController()
      ->RequestPermissionsFromCurrentDocument(
          rfh, permission_request_description,
          base::BindOnce(
              &MediaStreamDevicesController::PromptAnsweredGroupedRequest,
              std::move(controller)));
}

MediaStreamDevicesController::~MediaStreamDevicesController() {
  if (!callback_.is_null()) {
    std::move(callback_).Run(blink::mojom::StreamDevicesSet(),
                             blink::mojom::MediaStreamRequestResult::
                                 FAILED_DUE_TO_SHUTDOWN_CONTROLLER_DESTRUCTOR,
                             false, {}, {});
  }
}

MediaStreamDevicesController::MediaStreamDevicesController(
    content::WebContents* web_contents,
    MediaStreamDeviceEnumerator* enumerator,
    const content::MediaStreamRequest& request,
    ResultCallback callback)
    : web_contents_(web_contents),
      enumerator_(enumerator),
      request_(request),
      callback_(std::move(callback)) {
  DCHECK(network::IsUrlPotentiallyTrustworthy(request_.security_origin) ||
         request_.request_type == blink::MEDIA_OPEN_DEVICE_PEPPER_ONLY);

  if (!enumerator_)
    enumerator_ = &owned_enumerator_;

  denial_reason_ = blink::mojom::MediaStreamRequestResult::OK;
  audio_setting_ = GetContentSetting(blink::PermissionType::AUDIO_CAPTURE,
                                     request, &denial_reason_);
  video_setting_ = GetContentSetting(blink::PermissionType::VIDEO_CAPTURE,
                                     request, &denial_reason_);

#if BUILDFLAG(IS_ANDROID)
  content::RenderFrameHost* initial_rfh = content::RenderFrameHost::FromID(
      request_.render_process_id, request_.render_frame_id);
  if (initial_rfh && initial_rfh == web_contents_->GetPrimaryMainFrame()) {
    request_main_frame_url_ = initial_rfh->GetLastCommittedURL();
  }
#endif
}

bool MediaStreamDevicesController::ShouldRequestAudio() const {
  return audio_setting_ == CONTENT_SETTING_ASK;
}

bool MediaStreamDevicesController::ShouldRequestVideo() const {
  return video_setting_ == CONTENT_SETTING_ASK;
}

blink::mojom::StreamDevicesSetPtr MediaStreamDevicesController::GetDevices(
    ContentSetting audio_setting,
    ContentSetting video_setting) {
  bool audio_allowed = audio_setting == CONTENT_SETTING_ALLOW;
  bool video_allowed = video_setting == CONTENT_SETTING_ALLOW;

  blink::mojom::StreamDevicesSetPtr stream_devices_set =
      blink::mojom::StreamDevicesSet::New();
  if (!audio_allowed && !video_allowed)
    return nullptr;

  // TODO(crbug.com/40216442): Generalize to multiple streams.
  stream_devices_set->stream_devices.emplace_back(
      blink::mojom::StreamDevices::New());
  blink::mojom::StreamDevices& devices = *stream_devices_set->stream_devices[0];
  switch (request_.request_type) {
    case blink::MEDIA_OPEN_DEVICE_PEPPER_ONLY: {
      // For open device request, when requested device_id is empty, pick
      // the first available of the given type. If requested device_id is
      // not empty, return the desired device if it's available. Otherwise,
      // return no device.
      if (audio_allowed &&
          request_.audio_type ==
              blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE) {
        DCHECK_EQ(blink::mojom::MediaStreamType::NO_SERVICE,
                  request_.video_type);
        devices.audio_device =
            enumerator_->GetPreferredAudioDeviceForBrowserContext(
                web_contents_->GetBrowserContext(),
                request_.requested_audio_device_ids);
      } else if (video_allowed &&
                 request_.video_type ==
                     blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE) {
        DCHECK_EQ(blink::mojom::MediaStreamType::NO_SERVICE,
                  request_.audio_type);
        // Pepper API opens only one device at a time.
        devices.video_device =
            enumerator_->GetPreferredVideoDeviceForBrowserContext(
                web_contents_->GetBrowserContext(),
                request_.requested_video_device_ids);
      }
      break;
    }
    case blink::MEDIA_GENERATE_STREAM: {
      // Get the exact audio or video device if an id is specified.
      if (audio_allowed && !request_.requested_audio_device_ids.empty()) {
        devices.audio_device =
            enumerator_->GetPreferredAudioDeviceForBrowserContext(
                web_contents_->GetBrowserContext(),
                request_.requested_audio_device_ids);
      }
      if (video_allowed && !request_.requested_video_device_ids.empty()) {
        devices.video_device =
            enumerator_->GetPreferredVideoDeviceForBrowserContext(
                web_contents_->GetBrowserContext(),
                request_.requested_video_device_ids);
      }

      break;
    }
    case blink::MEDIA_GET_OPEN_DEVICE: {
      // Transferred tracks, that use blink::MEDIA_GET_OPEN_DEVICE type, do not
      // need to get permissions for MediaStreamDevice as those are controlled
      // by the original context.
      NOTREACHED();
    }
    case blink::MEDIA_DEVICE_ACCESS: {
      // Get the preferred devices for the request.
      if (audio_allowed) {
        // `SpeechRecognitionManagerImpl` doesn't provide a list of requested
        // devices, so just get the most preferred device without filtering.
        CHECK(request_.requested_audio_device_ids.empty());
        devices.audio_device =
            enumerator_->GetPreferredAudioDeviceForBrowserContext(
                web_contents_->GetBrowserContext(), {});
      }
      // MEDIA_DEVICE_ACCESS is only used for speech recognition, so it never
      // requests video access.
      CHECK(!video_allowed);
      break;
    }
    case blink::MEDIA_DEVICE_UPDATE: {
      NOTREACHED();
    }
  }  // switch

  return stream_devices_set;
}

void MediaStreamDevicesController::RunCallback(
    bool blocked_by_permissions_policy) {
  CHECK(callback_);

  blink::mojom::StreamDevicesSetPtr stream_devices_set;
  // If all requested permissions are allowed then the callback should report
  // success, otherwise we report |denial_reason_|.
  blink::mojom::MediaStreamRequestResult request_result =
      blink::mojom::MediaStreamRequestResult::OK;
  if ((audio_setting_ == CONTENT_SETTING_ALLOW ||
       audio_setting_ == CONTENT_SETTING_DEFAULT) &&
      (video_setting_ == CONTENT_SETTING_ALLOW ||
       video_setting_ == CONTENT_SETTING_DEFAULT)) {
    stream_devices_set = GetDevices(audio_setting_, video_setting_);
    if (stream_devices_set->stream_devices.empty()) {
      // Even if all requested permissions are allowed, if there are no devices
      // at this point we still report a failure.
      request_result = blink::mojom::MediaStreamRequestResult::NO_HARDWARE;
    }
  } else {
    DCHECK_NE(blink::mojom::MediaStreamRequestResult::OK, denial_reason_);
    request_result = denial_reason_;
    stream_devices_set = blink::mojom::StreamDevicesSet::New();
  }

  std::move(callback_).Run(*stream_devices_set, request_result,
                           blocked_by_permissions_policy, audio_setting_,
                           video_setting_);
}

ContentSetting MediaStreamDevicesController::GetContentSetting(
    blink::PermissionType permission,
    const content::MediaStreamRequest& request,
    blink::mojom::MediaStreamRequestResult* denial_reason) const {
  DCHECK(permission == blink::PermissionType::AUDIO_CAPTURE ||
         permission == blink::PermissionType::VIDEO_CAPTURE);
  DCHECK(!request_.security_origin.is_empty());
  DCHECK(network::IsUrlPotentiallyTrustworthy(request_.security_origin) ||
         request_.request_type == blink::MEDIA_OPEN_DEVICE_PEPPER_ONLY);
  if (!PermissionIsRequested(permission, request)) {
    // No denial reason set as it will have been previously set.
    return CONTENT_SETTING_DEFAULT;
  }

  std::vector<std::string> device_ids;
  if (permission == blink::PermissionType::AUDIO_CAPTURE) {
    device_ids = request.requested_audio_device_ids;
  } else {
    device_ids = request.requested_video_device_ids;
  }
  if (!HasAvailableDevices(permission, device_ids)) {
    *denial_reason = blink::mojom::MediaStreamRequestResult::NO_HARDWARE;
    return CONTENT_SETTING_BLOCK;
  }

#if BUILDFLAG(IS_ANDROID)
  if (!IsUserAcceptAllowedOnAndroid(permission)) {
    *denial_reason =
        blink::mojom::MediaStreamRequestResult::ANDROID_CANT_REQUEST_PERMISSION;
    return CONTENT_SETTING_BLOCK;
  }
#endif

  // Don't request if the kill switch is on.
  if (PermissionIsBlockedForReason(
          permission, content::PermissionStatusSource::KILL_SWITCH)) {
    *denial_reason = blink::mojom::MediaStreamRequestResult::KILL_SWITCH_ON;
    return CONTENT_SETTING_BLOCK;
  }

  return CONTENT_SETTING_ASK;
}

#if BUILDFLAG(IS_ANDROID)
bool MediaStreamDevicesController::IsUserAcceptAllowedOnAndroid(
    blink::PermissionType permission) const {
  ui::WindowAndroid* window_android =
      web_contents_->GetNativeView()->GetWindowAndroid();
  if (!window_android)
    return false;

  ContentSettingsType content_type;

  switch (permission) {
    case blink::PermissionType::AUDIO_CAPTURE:
      content_type = ContentSettingsType::MEDIASTREAM_MIC;
      break;
    case blink::PermissionType::VIDEO_CAPTURE:
      content_type = ContentSettingsType::MEDIASTREAM_CAMERA;
      break;
    default:
      NOTREACHED();
  }

  std::vector<std::string> required_android_permissions;
  permissions::AppendRequiredAndroidPermissionsForContentSetting(
      content_type, &required_android_permissions);
  for (const auto& android_permission : required_android_permissions) {
    if (!window_android->HasPermission(android_permission) &&
        !window_android->CanRequestPermission(android_permission)) {
      return false;
    }
  }

  // Don't approve device requests if the tab was hidden, unless there is an
  // active Picture-in-Picture document.
  // TODO(raymes): Shouldn't this apply to all permissions not just audio/video?
  return web_contents_->GetRenderWidgetHostView()->IsShowing() ||
         web_contents_->HasPictureInPictureDocument();
}
#endif

content::RenderFrameHost*
MediaStreamDevicesController::GetTargetRenderFrameHost() const {
  content::RenderFrameHost* rfh = content::RenderFrameHost::FromID(
      request_.render_process_id, request_.render_frame_id);
#if BUILDFLAG(IS_ANDROID)
  // On Android, presenting the OS-level permission dialog can pause/resume the
  // activity and invalidate or replace the original RenderFrameHost. If the
  // request originally came from the primary main frame, ensure the frame is
  // active and has not navigated away. If the original RenderFrameHost is no
  // longer active, fall back to the active primary main frame if the URL has
  // not changed.
  if (request_main_frame_url_.has_value() && web_contents_) {
    content::RenderFrameHost* main_rfh = web_contents_->GetPrimaryMainFrame();
    if (rfh && rfh->IsActive() && rfh == main_rfh) {
      return (rfh->GetLastCommittedURL() == *request_main_frame_url_) ? rfh
                                                                      : nullptr;
    }
    if (main_rfh && main_rfh->IsActive() &&
        main_rfh->GetLastCommittedURL() == *request_main_frame_url_) {
      return main_rfh;
    }
    return nullptr;
  }
#endif
  return rfh;
}

bool MediaStreamDevicesController::PermissionIsBlockedForReason(
    blink::PermissionType permission,
    content::PermissionStatusSource reason) const {
  content::RenderFrameHost* rfh = GetTargetRenderFrameHost();
  if (!rfh ||
      rfh->GetLastCommittedOrigin().GetURL() != request_.security_origin) {
    return false;
  }

  // TODO(raymes): This function wouldn't be needed if
  // PermissionManager::RequestPermissions returned a denial reason.
  content::PermissionResult result =
      web_contents_->GetBrowserContext()
          ->GetPermissionController()
          ->GetPermissionResultForCurrentDocument(
              content::PermissionDescriptorUtil::
                  CreatePermissionDescriptorForPermissionType(permission),
              rfh);
  if (result.source == reason) {
    DCHECK_EQ(blink::mojom::PermissionStatus::DENIED, result.status);
    return true;
  }
  return false;
}

void MediaStreamDevicesController::PromptAnsweredGroupedRequest(
    const std::vector<content::PermissionResult>& permission_result) {
  content::RenderFrameHost* rfh = GetTargetRenderFrameHost();
  if (!rfh) {
    // The frame requesting media devices was removed while we were waiting for
    // a user response on permissions. Nothing more to do.
    return;
  }

  std::vector<ContentSetting> responses;
  std::ranges::transform(
      permission_result, back_inserter(responses),

      [](content::PermissionResult result) {
        return permissions::PermissionUtil::PermissionStatusToContentSetting(
            result.status);
      });

  bool need_audio = ShouldRequestAudio();
  bool need_video = ShouldRequestVideo();
  bool blocked_by_permissions_policy = need_audio || need_video;
  // The audio setting will always be the first one in the vector, if it was
  // requested.
  if (need_audio) {
    audio_setting_ = responses.front();
    blocked_by_permissions_policy &=
        audio_setting_ == CONTENT_SETTING_BLOCK &&
        PermissionIsBlockedForReason(
            blink::PermissionType::AUDIO_CAPTURE,
            content::PermissionStatusSource::FEATURE_POLICY);
  }

  if (need_video) {
    video_setting_ = responses.at(need_audio ? 1 : 0);
    blocked_by_permissions_policy &=
        video_setting_ == CONTENT_SETTING_BLOCK &&
        PermissionIsBlockedForReason(
            blink::PermissionType::VIDEO_CAPTURE,
            content::PermissionStatusSource::FEATURE_POLICY);
  }

  for (ContentSetting response : responses) {
    if (response == CONTENT_SETTING_BLOCK)
      denial_reason_ = blink::mojom::MediaStreamRequestResult::
          PERMISSION_DENIED_BY_CONTROLLER;
    else if (response == CONTENT_SETTING_ASK)
      denial_reason_ =
          blink::mojom::MediaStreamRequestResult::PERMISSION_DISMISSED;
  }

  RunCallback(blocked_by_permissions_policy);
}

bool MediaStreamDevicesController::HasAvailableDevices(
    blink::PermissionType permission,
    const std::vector<std::string>& device_ids) const {
  const MediaStreamDevices* devices = nullptr;
  if (permission == blink::PermissionType::AUDIO_CAPTURE) {
    devices = &enumerator_->GetAudioCaptureDevices();
  } else if (permission == blink::PermissionType::VIDEO_CAPTURE ||
             permission == blink::PermissionType::CAMERA_PAN_TILT_ZOOM) {
    devices = &enumerator_->GetVideoCaptureDevices();
  } else {
    NOTREACHED();
  }

  // TODO(tommi): It's kind of strange to have this here since if we fail this
  // test, there'll be a UI shown that indicates to the user that access to
  // non-existing audio/video devices has been denied.  The user won't have
  // any way to change that but there will be a UI shown which indicates that
  // access is blocked.
  if (devices->empty())
    return false;

  // If there are no particular device requirements, all devices will do.
  if (device_ids.empty() &&
      permission != blink::PermissionType::CAMERA_PAN_TILT_ZOOM) {
    return true;
  }

  // Try to find a device which fulfils all device requirements.
  for (const blink::MediaStreamDevice& device : *devices) {
    if (!device_ids.empty() && std::find(device_ids.begin(), device_ids.end(),
                                         device.id) == device_ids.end()) {
      continue;
    }
    if (permission == blink::PermissionType::CAMERA_PAN_TILT_ZOOM &&
        !device.video_control_support.pan &&
        !device.video_control_support.tilt &&
        !device.video_control_support.zoom) {
      continue;
    }
    return true;
  }

  return false;
}

}  // namespace webrtc
