【iOS】SwiftUIで画面キャプチャからコンテンツを保護する

この記事は[レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku)の3日目の記事となります。

## はじめに

こんにちは、永田です。
株式会社レコチョクでiOSアプリ開発をしています。

今年の個人的ベストライブは[凛として時雨 TOUR 2024 Pierrrrrrrrrrrrrrrrrrrre Vibes](https://www.sonymusic.co.jp/artist/lingtositesigure/info/562743)です。
古参大歓喜のセットリストで最高でした。映像化されないかなーと楽しみにしています。

---

さて、今回はアプリ上で提供しているコンテンツを画面キャプチャから保護する方法についてご紹介します。

## おことわり

本記事では、以下の2つをまとめて「画面キャプチャ」と称します。

- スクリーンショット
  - 電源ボタン+音量ボタン（上）or電源ボタン+ホームボタンの同時押下で撮影できる画像
- 画面収録
  - コントロールセンターの「画面収録」から撮影できる動画

## 実現したいこと・方針

音源・動画を再生できるアプリにおいて、それぞれのコンテンツを画面キャプチャから保護できるようにしたいと考えました。
具体的には以下のような状態を実現する必要があります。

- 音源
  - 画面収録にコンテンツの音声が含まれない
- 動画
  - スクリーンショットに映像の一部が含まれない
  - 画面収録に動画の内容（映像・音声）が含まれない

今回はこの状態を実現するために、それぞれ以下のような方針で対応を進めることとしました。

- 画面収録中に音声の再生ボリュームを0にする
  - 音源・動画用の対策
- スクリーンショット・画面収録を検知して動画再生Viewをマスクする
  - 動画用の対策

## 画面キャプチャを検知する技術

ここまでで、どういった方針で画面キャプチャからコンテンツを保護するかは決まりました。
しかし、それらを実現するにはどのような技術を使えばいいのでしょうか？

基本的には、画面キャプチャが実行・開始されたイベントを検知して、コンテンツ保護のための処理を実行すれば良さそうです。これを検知できる方法には以下のようなものがあります。

- スクリーンショット
  - [`UIApplication.userDidTakeScreenshotNotification`](https://developer.apple.com/documentation/uikit/uiapplication/1622966-userdidtakescreenshotnotificatio)
- 画面収録
  - [`UIScreen.isCaptured`](https://developer.apple.com/documentation/uikit/uiscreen/2921651-iscaptured) (deprecated in iOS 17.2)
  - [`UIScreen.capturedDidChangeNotification`](https://developer.apple.com/documentation/uikit/uiscreen/2921652-captureddidchangenotification)
  - [`UITraitCollection.sceneCaptureState`](https://developer.apple.com/documentation/uikit/uitraitcollection/4292628-scenecapturestate) (iOS 17.0+)
  - [`@Environment(\.isSceneCaptured)`](https://developer.apple.com/documentation/swiftui/environmentvalues/isscenecaptured) (iOS 17.0+)

これを踏まえて、画面キャプチャからコンテンツを保護する実装を見ていきましょう。

## 画面収録中に音声の再生ボリュームを0にする

画面収録の開始・終了を検知して、コンテンツを再生している`AVPlayer`の[`volume`](https://developer.apple.com/documentation/avfoundation/avplayer/1390127-volume)を更新しましょう。

iOS 17以降をサポートしている場合は、`@Environment(\.isSceneCaptured)`を用いて以下のように実装できます。

```swift
import AVFoundation
import SwiftUI

struct ContentView: View {
    @Environment(\.isSceneCaptured) private var isSceneCaptured
    private let player = AVPlayer()

    var body: some View {
        VStack {
            ...
        }
        .onChange(
            of: isSceneCaptured,
            initial: true
        ) {
            player.volume = isSceneCaptured ? 0 : 1
        }
    }
}
```

iOS 17未満をサポートしている場合は、deprecatedではありますが`UIScreen.isCaptured`を使う必要があります。

```swift
import AVFoundation
import SwiftUI

struct ContentView: View {
    private let player = AVPlayer()

    var body: some View {
        VStack {
            ...
        }
        .onReceive(
            NotificationCenter
                .default
                .publisher(for: UIScreen.capturedDidChangeNotification)
        ) {
            guard let screen = $0.object as? UIScreen else {
                return
            }
            player.volume = screen.isCaptured ? 0 : 1
        }
    }
}
```

## スクリーンショット・画面収録を検知して動画再生Viewをマスクする

画面収録を検知して音声の再生ボリュームを変更するのは比較的簡単に実装できました。
それでは動画再生Viewのマスクはどうでしょうか？

画面収録は音声と同じ方法で検知し動画再生Viewをマスクできそうですが、実はスクリーンショットの対策が鬼門なのです。

なぜなら、スクリーンショットを検知できる`UIApplication.userDidTakeScreenshotNotification`はスクリーンショットの**撮影後**に通知されるのみであり、撮影前に対策処理を実行することができないためです。

また、画面収録の検知に使った`UIScreen.isCaptured`, `UIScreen.capturedDidChangeNotification`ではスクリーンショットの撮影が検知できません。

ではどのようにコンテンツを保護すれば良いのでしょうか？

### `UITextField`を使う

ここで`UITextField`の登場です。
[`UITextField.isSecureTextEntry`](https://developer.apple.com/documentation/uikit/uitextinputtraits/1624427-issecuretextentry)を`true`にした時に、入力した内容が画面キャプチャから除外される性質を利用します。

これは、`UITextField`のsubviewである`_UITextLayoutCanvasView`によって実現されています。このViewは、親となる`UITextField`の`isSecureTextEntry`が`true`になった時に、自身のsubviewを画面キャプチャから除外するという性質を持っています。

![_UITextLayoutCanvasView](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/74718273-d6f8-d98c-0d62-da8c1c01aa0a.png)

これを利用し、`isSecureTextEntry`を`true`にした`UITextField`から
`_UITextLayoutCanvasView`を取り出し、保護したいコンテンツを表示するViewを`addSubview`する方針で実装します。

今回はSwiftUIから使えるように実装した内容をご紹介します。

#### `SwiftUI.View`を保護する

`SwiftUI.View`を受け取り、それを`_UITextLayoutCanvasView`で保護するViewControllerを実装します。

`_UITextLayoutCanvasView`は、OSバージョンによって名称が変わり得るため、ある程度緩めの条件で一致判定をしました。

```swift
/// スクリーンショット・画面収録からContentを保護するViewController
private final class CapturePreventingViewController<Content: View>: UIViewController {
    /// addSubviewされたViewを保護するView
    ///
    /// 実態は_UITextLayoutCanvasView
    private lazy var secureContainerView: UIView? = {
        $0.isUserInteractionEnabled = false
        $0.isSecureTextEntry = shouldPreventCapture
        let canvasView = $0.subviews.first {
            let className = String(describing: type(of: $0))
            return className.starts(with: "_") && className.contains("CanvasView")
        }
        canvasView?.isUserInteractionEnabled = true
        return canvasView
    }(UITextField())

    private let shouldPreventCapture: Bool
    private let content: () -> Content

    init(
        _ shouldPreventCapture: Bool,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.shouldPreventCapture = shouldPreventCapture
        self.content = content
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        guard let secureContainerView else {
            return
        }

        view.addSubview(secureContainerView)
        secureContainerView.fillToSuperview()

        // 渡されたViewをsecureContainerViewで保護する
        let host = UIHostingController(rootView: content())
        addChild(host)
        secureContainerView.addSubview(host.view)
        host.view.fillToSuperview()
        host.didMove(toParent: self)
    }
}

extension UIView {
    func fillToSuperview() {
        guard let superview else {
            return
        }
        translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            topAnchor.constraint(equalTo: superview.topAnchor),
            bottomAnchor.constraint(equalTo: superview.bottomAnchor),
            leadingAnchor.constraint(equalTo: superview.leadingAnchor),
            trailingAnchor.constraint(equalTo: superview.trailingAnchor)
        ])
    }
}
```

#### SwiftUIから呼び出せるようにする

先ほど実装した`CapturePreventingViewController`を`UIViewControllerRepresentable`でラップし、SwiftUIから呼び出せるようにします。

```swift
struct CapturePreventingView<Content: View>: UIViewControllerRepresentable {
    private let shouldPreventCapture: Bool
    private let content: () -> Content

    init(
        _ shouldPreventCapture: Bool,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.shouldPreventCapture = shouldPreventCapture
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIViewController {
        CapturePreventingViewController(
            shouldPreventCapture,
            content: content
        )
    }

    func updateUIViewController(
        _ uiViewController: UIViewController,
        context: Context
    ) {
    }
}
```

#### モディファイア化する

モディファイア化してより呼び出しやすくします。

```swift
extension View {
    func preventCapture(_ shouldPreventCapture: Bool) -> some View {
        modifier(CapturePreventingModifier(shouldPreventCapture: shouldPreventCapture))
    }
}

private struct CapturePreventingModifier: ViewModifier {
    let shouldPreventCapture: Bool

    func body(content: Content) -> some View {
        CapturePreventingView(shouldPreventCapture) {
            content
        }
    }
}
```

#### 挙動の確認

実装した`preventCapture(_:)`を実行すると、以下のような挙動になります。

```swift
struct CapturePreventSampleView: View {
    var body: some View {
        VStack {
            HStack {
                Image(systemName: "lock")
                Text("Protected Contents")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.green)
            .preventCapture(true)

            HStack {
                Image(systemName: "lock.open")
                Text("Not Protected Contents")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.red)
        }
        .padding()
    }
}
```

![実装結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/b84532f1-00f3-bde5-78b0-8c7a88d1dc9a.gif)

モディファイアを使用したViewが問題なく保護されていることが確認できました。
添付したGIFではわかりませんが、スクリーンショットだけでなく画面収録からも同じ実装で保護できます。

### リスクと回避策

前述の実装で無事要件を満たすことはできましたが、この実装には

- OSアップデートにより、`_UITextLayoutCanvasView`の名前が変更され、正しく保護されなくなる
- プライベートAPIを使用しているため、Appleの審査に落ちてしまう

などのリスクが考えられます。

動画コンテンツをキャプチャから保護する実装は[FairPlay Streaming](https://developer.apple.com/streaming/fps/)で実現可能であるため、本来はこちらで実装するべきだと思います。

あくまで動画の場合はFairPlay Streamingを導入するまでの暫定実装として、動画以外の場合はどうしてもスクリーンショットから保護する必要がある場合のみ使うのがいいのかなと思います。

## 終わりに

今回は画面キャプチャからアプリ内のコンテンツを保護する方法をご紹介しました。

画面収録を検知して保護することは比較的簡単ですが、スクリーンショットから保護するのは意外と大変だということが伝わったのではないでしょうか。

`_UITextLayoutCanvasView`を使う方法にはリスクが伴うため、どうしてもスクリーンショットから保護しないといけないのかなどはしっかり考慮した上で実装することをお勧めします。

明日の [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) は4日目「大規模サービスのAmazon Aurora MySQLのテーブル変更で直面した3つの課題」です。お楽しみに！

## 参考

- [iOSでスクショ、画面録画防止するためのベストプラクティス #UIKit - Qiita](https://qiita.com/chitomo12/items/101555bb230eabbf1b3c)
- [yoxisem544/ScreenshotPreventing-iOS: Prevent screenshot or screenrecording on iOS devices](https://github.com/yoxisem544/ScreenshotPreventing-iOS)

---

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/11254