この記事はレコチョク Advent Calendar 2024の3日目の記事となります。
はじめに
こんにちは、永田です。
株式会社レコチョクでiOSアプリ開発をしています。
今年の個人的ベストライブは凛として時雨 TOUR 2024 Pierrrrrrrrrrrrrrrrrrrre Vibesです。
古参大歓喜のセットリストで最高でした。映像化されないかなーと楽しみにしています。
さて、今回はアプリ上で提供しているコンテンツを画面キャプチャから保護する方法についてご紹介します。
おことわり
本記事では、以下の2つをまとめて「画面キャプチャ」と称します。
- スクリーンショット
- 電源ボタン+音量ボタン(上)or電源ボタン+ホームボタンの同時押下で撮影できる画像
- 画面収録
- コントロールセンターの「画面収録」から撮影できる動画
実現したいこと・方針
音源・動画を再生できるアプリにおいて、それぞれのコンテンツを画面キャプチャから保護できるようにしたいと考えました。
具体的には以下のような状態を実現する必要があります。
- 音源
- 画面収録にコンテンツの音声が含まれない
- 動画
- スクリーンショットに映像の一部が含まれない
- 画面収録に動画の内容(映像・音声)が含まれない
今回はこの状態を実現するために、それぞれ以下のような方針で対応を進めることとしました。
- 画面収録中に音声の再生ボリュームを0にする
- 音源・動画用の対策
- スクリーンショット・画面収録を検知して動画再生Viewをマスクする
- 動画用の対策
画面キャプチャを検知する技術
ここまでで、どういった方針で画面キャプチャからコンテンツを保護するかは決まりました。
しかし、それらを実現するにはどのような技術を使えばいいのでしょうか?
基本的には、画面キャプチャが実行・開始されたイベントを検知して、コンテンツ保護のための処理を実行すれば良さそうです。これを検知できる方法には以下のようなものがあります。
- スクリーンショット
- 画面収録
-
UIScreen.isCaptured
(deprecated in iOS 17.2) UIScreen.capturedDidChangeNotification
-
UITraitCollection.sceneCaptureState
(iOS 17.0+) -
@Environment(\.isSceneCaptured)
(iOS 17.0+)
-
これを踏まえて、画面キャプチャからコンテンツを保護する実装を見ていきましょう。
画面収録中に音声の再生ボリュームを0にする
画面収録の開始・終了を検知して、コンテンツを再生しているAVPlayer
のvolume
を更新しましょう。
iOS 17以降をサポートしている場合は、@Environment(\.isSceneCaptured)
を用いて以下のように実装できます。
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
を使う必要があります。
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
をtrue
にした時に、入力した内容が画面キャプチャから除外される性質を利用します。
これは、UITextField
のsubviewである_UITextLayoutCanvasView
によって実現されています。このViewは、親となるUITextField
のisSecureTextEntry
がtrue
になった時に、自身のsubviewを画面キャプチャから除外するという性質を持っています。
これを利用し、isSecureTextEntry
をtrue
にしたUITextField
から
_UITextLayoutCanvasView
を取り出し、保護したいコンテンツを表示するViewをaddSubview
する方針で実装します。
今回はSwiftUIから使えるように実装した内容をご紹介します。
SwiftUI.View
を保護する
SwiftUI.View
を受け取り、それを_UITextLayoutCanvasView
で保護するViewControllerを実装します。
_UITextLayoutCanvasView
は、OSバージョンによって名称が変わり得るため、ある程度緩めの条件で一致判定をしました。
/// スクリーンショット・画面収録から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から呼び出せるようにします。
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
) {
}
}
モディファイア化する
モディファイア化してより呼び出しやすくします。
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(_:)
を実行すると、以下のような挙動になります。
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()
}
}
モディファイアを使用したViewが問題なく保護されていることが確認できました。
添付したGIFではわかりませんが、スクリーンショットだけでなく画面収録からも同じ実装で保護できます。
リスクと回避策
前述の実装で無事要件を満たすことはできましたが、この実装には
- OSアップデートにより、
_UITextLayoutCanvasView
の名前が変更され、正しく保護されなくなる - プライベートAPIを使用しているため、Appleの審査に落ちてしまう
などのリスクが考えられます。
動画コンテンツをキャプチャから保護する実装はFairPlay Streamingで実現可能であるため、本来はこちらで実装するべきだと思います。
あくまで動画の場合はFairPlay Streamingを導入するまでの暫定実装として、動画以外の場合はどうしてもスクリーンショットから保護する必要がある場合のみ使うのがいいのかなと思います。
終わりに
今回は画面キャプチャからアプリ内のコンテンツを保護する方法をご紹介しました。
画面収録を検知して保護することは比較的簡単ですが、スクリーンショットから保護するのは意外と大変だということが伝わったのではないでしょうか。
_UITextLayoutCanvasView
を使う方法にはリスクが伴うため、どうしてもスクリーンショットから保護しないといけないのかなどはしっかり考慮した上で実装することをお勧めします。
明日の レコチョク Advent Calendar 2024 は4日目「大規模サービスのAmazon Aurora MySQLのテーブル変更で直面した3つの課題」です。お楽しみに!
参考
- iOSでスクショ、画面録画防止するためのベストプラクティス #UIKit - Qiita
- yoxisem544/ScreenshotPreventing-iOS: Prevent screenshot or screenrecording on iOS devices
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。