はじめに
こんにちは、久しぶりに髪の毛を派手髪にした直後にメイリッシュでバニーガール集団を見ながら書いている記事です。*1
なお、1日で書き終わることもなく無事後日持ち越しでオフィスで書いてます。
今回は、仕事で作った iOS アプリのサービスが終了したあとにメンバーから公開してもいいという了承をもらって(了承というか書いてほしいと言われた)、私もそろそろ新しい記事を書きたいなという感じになっていたのでアウトプット的に書きます!
この記事本当は、 iOSアプリが終了している状態でスクショをサーバにアップロードする仕組みはターゲット層に怖いと言われた
というタイトルだったのでこれ以降、このタイトル前提で書かれてる部分があるので許してください!!
このアプリのターゲット層は 1,000% このブログ読まないと思うので、一番最後になぜ怖いと感じられてしまったのかと書きたい。
ちなみにこんな感じにサービスは幕を閉じた
直近でピボットしたサービスを本日シャットダウンしたのでエモい pic.twitter.com/CKVTxqLjRc
— Khan Shaheem カーン シャヒーム (@Shaheemdesu) August 19, 2019
サービスを終了いたします。 pic.twitter.com/eIVt8tZXK2
— 小泉ひやかし⛵️ (@nnsnodnb) August 19, 2019
先にサービスの紹介をさせてください
Pipe というスクショを親友間だけで共有するアプリでした。
差別化のポイント
- ユーザが自分自身で投稿をしなくても自動的に投稿される(投稿されて24時間したら見られなくなる)
- 友達以外には見られない
- トークスクショ(LINE や Twitter DM 、 Facebook Messenger 、 Instagram DM 等)のスクショは公開されない
- プッシュ通知をオフにしていても写真ライブラリへのアクセスとネットワークと電池があれば新しいスクショを自動でアップロードする
技術スタック及び環境等
サーバサイド
インフラ(AWS ほんのごく一部抜粋。)
- EC2
- RDS
- S3
- CloudFront
- Lambda
- ElastiCache (Redis)
一応こんな感じなことは書いてますけど、ぶっちゃけサーバサイドとインフラは機能するんであればなんでもいい。
サーバサイド系で必要になってくるコード類は、問答無用で Python なコードになってくるのでそこは許してください!!!
概要
PushKit
というものを使用します。
以前 VoIP
プッシュについて記事を書いてたりしてたんですが、そういうことでした。
ちなみに以下記事。
ついでに、 APNS
と VoIP
プッシュを Python
で送信できるライブラリを作ったので Star ほしい
PushKit
を使ってバックグラウンドで実行できる時間が 30秒
程度あるらしいです。ここは APNS プッシュ通知のバックグラウンドと同じらしいです。*2
PushKit
の認証トークンは APNS
のデバイストークンと違って、開発者側の任意のタイミングで取得できる & ユーザに対してプッシュ通知を送信しますのような表示を出さなくてもいい。というメリットがあります。詳しくは以下記事がいいかな
PushKit
を選んだ理由
どうして PushKit
にたどり着いたかというと、 Zenly
というアプリ上の友達同士で位置情報を共有できる メンヘラ彼女が彼氏を監視するために作られたような アプリがありますね。ごめんなさい。許してください!なんでもしますから!!(なんでもするとは言っていない)
このアプリは、メンバー(私を含める)の実験結果、位置情報をオンにしてさえいれば、ネットワーク環境と電池があれば友達がアプリを開いてたら自動的に位置情報をサーバに送信しているということがわかりました。
はじめは、プッシュ通知のサイレントプッシュでもしてるんだろうなぁとか、位置情報が変わったら自動的に送信するような仕組みなんだろうなとか思ってたんですけど、私の知る限り自分がアプリを終了およびプッシュ通知を切っている状態、友達がアプリを開いているときにほぼリアルタイムに更新されるような仕組みは、 CoreLocation
や UserNotifications
を使っても無理だった。
ちなみに CoreLocation
でアプリが終了している状態でバックグランド処理をさせたい場合は
- OS がよしなに位置情報が大きく動いたらイベント発火
- 指定した位置情報に達したらイベント発火
前者の例をあげると、 Dropbox
のカメラロールバックアップ機能。
そんで、先述通りの調査等を含めて丸2日間、 Google 先生に英語、中国語、ロシア語でお世話になりなんとか PushKit
を使ったらこれと同じようなことができるのではないかという結論に至った。
本アプリでは、2分毎に Apple の VoIP
プッシュサーバに向けて全発射を行っている。
電池持ちはどうなの?
以前 Facebook が無音の音を無限に鳴らし続けていたとかいう事件*3があったと思う。それは電池消耗まじでヤバそうだと思った。
先述した Zenly
というアプリ。こちらも以前電池消耗が内部で問題*4になったという話を聞いた。
本アプリでは、新しいスクショがあるかどうかを確認したらすぐに処理を止める(正しくは PKPushRegistryDelegate
の pushRegistry(_:didReceiveIncomingPushWith:for:completion:)
メソッドの completion
を呼ぶ)
1時間に 3% ぐらいの電池消耗なのでまぁぼちぼちかなといったところだった。スクショがアップロードされたり新しいスクショがなかったパターンでランダム的にテストを行った。
ここでのテストケースは1年以上普段使いした iPhoneX で行ったりしたので消耗は仕方ないっていう感じ。
PushKit
を使ったアプリを実装した場合の実装デメリット
これはリジェクトされるまで知らなかったことなんですが CallKit
を使った電話システムを実装しろっていう感じでした。
対策として、 WebRTC
を使った通話機能を実装しました。
初めてのリリースをするために Apple と戦った形跡です。
はじめのリジェクトで私が介入するべきだったとちょっとだけ後悔。ビルド5がリジェクトされた時点で私が介入して PushKit
使ってるんなら CallKit
も使ってねということをようやく Apple のレビュアーさんから引き出すことに成功しました...
通話機能は本当は Asterisk
を使おうかなって思ったんですが、ローカルで建てて端末同士でテストしてたのですが音声がうまくいかなかった*5 のと自分が今後これをちゃんとメンテできるかと言ったら難しいなと思ってやめて、 WebRTC
を初めて実装しました。*6
あと、Apple の通常の想定動作とは違う処理をしているので、 PushKit
でアプリがバックグラウンドのみで立ち上がっているので、 AppDelegate
の application(_:didFinishLaunchingWithOptions:)
メソッドに到達してきます。なので次回のユーザが自身でアプリを起動するとAPIの通信が急に失敗しましたみたいなことになりました。
今回は、アプリが起動していない状態である場合は、 exit(0)
を呼んでアプリを終了させるということにして対応しました。
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { somethings() completion() // バックグラウンド && アプリが起動していない guard UIApplication.shared.applicationState == .background && !AppDelegate.shared.didLaunch else { return } exit(0) }
AppDelegate.shared.didLaunch
ってなんやねんって感じあるかと思いますが。
@UIApplicationMain final class AppDelegate: UIResponder, UIApplicationDelegate { class var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate } private(set) var didLaunch = false ・・・ }
という感じで宣言を入れてます。 applicationDidBecomeActive(_:)
にてそれぞれの分岐処理の最後に didLaunch = true
としています。
実装コード(抜粋等)
Python で PushKit
の配信をする
さっき紹介したやつを使ってみます。
$ pip install kalyke-apns
from kalyke.client import VoIPClient from pathlib import Path client = VoIPClient( auth_key_filepath=Path(__file__).parent / 'path' / 'to' / 'YOUR_VOIP_CERTIFICATE.pem', bundle_id='com.example.App.voip', use_sandbox=True ) alert = { 'type': 'upload_screenshots', 'fetch_limit': 10 } # ここは PushKit の認証トークン registration_id = '14924adeeabaacc8b38cfd766965abffd0ee572a5a89e7ee26e6009a3f1a8e8a' result = client.send_message(registration_id, alert)
PushKit
の実装 (一部抜粋)
PushKit
の認証トークンを取得するところはぱぱっと書いちゃいます。便宜上 AppDelegate.swift
に書いてると想定します。
import PushKit @UIApplicationMain final class AppDelegate: UIResponder, UIApplicationDelegate { private(set) var registry: PKPushRegistry! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { registry = PKPushRegistry(queue: .main) registry.delegate = self registry.desiredPushTypes = [.voIP] return true } ・・・ } extension AppDelegate: PKPushRegistryDelegate { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { // 認証トークンをサーバ等に送信する。FCM がこれに対応してくれたらどれだけ嬉しいか。 } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { /* 本来はCallKitを呼び出す実装もあります また、defer 内で先程書いたような exit(0) を条件によって実行していたりします。 */ defer { completion() } guard type == .voIP else { return } do { let data = try PushKitPayload(json: payload.dictionaryPayload) // このあとにスクショを写真ライブラリから取得したりアップロードする } catch { print(error.localizedDescription) } } }
別途で Codable
に準拠させた struct
を作っています。
struct PushKitPayload: Codable { let type: PushKitPayloadType let fetchLimit: Int private enum CodingKeys: String, CodingKey { case type case fetchLimit = "fetch_limit" } init(json: [AnyHashable: Any]) throws { let data = try JSONSerialization.data(withJSONObject: json, options: []) self = try JSONDecoder().decode(PushKitPayload.self, from: data) } } enum PushKitPayloadType: String, Codable { case uploadScreenshots = "upload_screenshots" }
写真ライブラリからスクショをフィルターして取得するやつからアップロード関係
4G LTE 環境でネットワーク環境も悪くなく、電池状況も良い状態で30秒以内にどれだけスクショをアップロードできるかということで10枚だったのでここでは、 fetch_limit: 10
と設定をしている。
OS がどれほどまでよしなに VoIP
プッシュを受け取ったときに電池状況に応じてバックグラウンドで起きていられる時間を作ってくれるかは今後の課題としておいたが、プロダクトが終わってしまったので無限に調べる気がなくなったのであった。ここらへん誰かやってくれてないかなぁ〜
なので、ネットワーク環境が悪くなく電池状況も良い状態で30秒間は OS がアプリを立ち上げてくれているということを前提条件として書いています。
アプリが初回起動(インストール直後、ユーザが初めてアプリ開いた)時に、 PushKit
の registry を登録してるときに起動時からのスクショをフィルターできるように UserDefaults なりに日付を保存しております。
UserDefaults.standard.set(Date(), forKey: "filter_screenshots_datetime_gt") UserDefaults.standard.synchronize()
上のやつを標準化してアップロードが終わったスクショの撮影時間を保存するようにしています。
スクショ取得部分がうまく行ってるかどうか不安(メモリリーク的な意味で)なので参考までに
import Photos struct Screenshot { let image: UIImage let creationDate: Date } lazy var fetchOptions: PHFetchOptions = { let creationDate = UserDefaults.standard.object(forKey: "filter_screenshots_datetime_gt") as! Date let fetchOptions = PHFetchOptions() fetchOptions.predicate = NSPredicate(format: "((mediaSubtype & %d) != 0) AND (creationDate > %@)", PHAssetMediaSubtype.photoScreenshot.rawValue, creationDate as CVarArg) fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] return fetchOptions }() func getScreenshots(fetchLimit: Int, completion: @escaping ([Screenshot] -> Void)) { guard PHPhotoLibrary.authorizationStatus() == .authorized else { completion([]) return } let options = fetchOptions options.fetchLimit = fetchLimit let fetchAssets = PHAsset.fetchAssets(with: .image, options: options) let screenshots: [Screenshot] = [] let manager = PHImageManager.default() let group = DispatchGroup() let dispatchQueue = DispatchQueue(label: "get_screenshots_queue", attributes: .concurrent) // enumerateObjectsは同期だがData→UIImageを別スレッドに逃がしているのでDispatchGroupを使用する fetchAssets.enumerateObjects { [unowned self] (asset, _, _) in group.enter() dispatchQueue.async(group: group) { self.getImage(fromAsset: asset, manager: manager) { if let screenshot = $0 { screenshots.append(screenshot) } group.leave() } } } group.notify(queue: .main) { // スクショを撮影日が古い順に並び替える completion(screenshots.sorted(by: { $0.creationDate < $1.creationDate })) } } func getImage(fromAsset: asset: PHAsset, manager: PHImageManager, completion: ((Screenshot?) -> Void)) { let options = PHImageRequestOptions() options.isSynchronous = true manager.requestImageData(for: asset, options: options) { (data, _, _) in // 画像サイズは小さくしています guard let data = data, let image = UIImage(data: data, scale: 10 / 3), let creationDate = asset.creationDate else { completion(nil) return } completion(Screenshot(image: image, creationDate: creationDate)) } }
一旦これで、古い順にスクショが getScreenshots(fetchLimit:completion)
を呼び出したら completion
にスクショが流れるようになりました。
アップロードすべきスクショが 0件
であれば配列は空です。
fetchLimit枚より多くあれば、 fetchLimit件
、fetchLimit枚未満であれば、 0 < n < fetchLimit
取得できます。
スクショの取得 と スクショのアップロード は別のメソッドを実装して中間処理として、先程のフィルターの時間を更新したりする処理を入れたりしてます。
次は、アップロードですね。ここの処理がやっぱり微妙であれだった。
// ここの completion は 中間メソッドの completion を渡してます func uploadScreenshots(_ screenshots: [Screenshot], completion: @escaping () -> Void) { var predicateDate = UserDefaults.standard.object(forKey: "filter_screenshots_datetime_gt") as! Date { didSet { /* UserDefaults の更新 */ } } let group = DispatchGroup() let configuration = URLSessionConfiguration.ephemeral // 同時接続数を1にする configuration.httpMaximumConnectionsPerHost = 1 let session = URLSession(configuration: configuration) let semaphore = DispatchSemaphore(value: 0) DispatchQueue(label: "screenshot_upload_queue").sync { screenshots.forEach { (screenshot) in group.enter() // ここは実装してください!!! // Gist でも付録でつけておきます! スクショアップロード { (_) in // 今回は失敗したか成功したかはとりあえず見ませんが、クロージャの型は Result を使ったもの if predicateDate < screenshot.creationDate { predicateDate = screenshot.creationDate } semaphore.signal() group.leave() } semaphore.wait() } } // 26秒以内に終わらなければ強制終了 _ = group.wait(timeout: .now() + 26) session.finishTasksAndInvalidate() // UserDefaults の更新 completion() }
iOS の URLSession
はデフォルトで4つのコネクションを作る設定になっています。*7 なので同期的に1つずつアップロードしたい今回の場合はこのようなコードになりました。一応私が実装してテストしていた限りではちゃんとこれでできていたのでメモリリークとか以外は問題ないかと思います。もしこうやったらいいんじゃない?とかあったら教えてくださると勉強になります!!!
大体の実装はこんな感じになりました。 GCD
の使い方がいまいち掴めてなくて難しい。ここらへんちゃんとできるようになりたい
なぜ怖いと感じられてしまったのか
- トークスクショが他人(アプリ内の友達)に見られてしまう
- 勝手にスクショがアップロードされてしまう
実際に聞き込みをしていない私の考えでは、上記2点なのではないかなって思っています。
トークスクショが他人(アプリ内の友達)に見られてしまう
これに関しては、プロダクトモデルとして トークスクショは他の誰からも見られないよ! という実装モデルになっていました。それもウォークスルーやチュートリアル動画や利用規約にも記述がある。
でも、そんなのJKが読む?読まないよね。(読んでくれた人はありがとう!)
ハイ次!!!
勝手にスクショがアップロードされてしまう
投稿いらずでシステムが自動的にスクショをアップロードするよ!
実際の説明はこんな簡易的ではなかったと思うんだけどここではもうこの説明でいいよね?
新しい試みだと思うですが、言い返せば スクショ撮ったら全部勝手にアップロードするよ ということになるわけだ。
我々のブランドライフビジョンは
「ありのままのつながり」を忘れさせない
という感じのものなわけだったが、実際のエンドユーザはそんなビジョン知らんわけでどうやってこれを伝えるかというのも内部で議論されてたりした。私は難しいことはわからないのであんまり参加してない。
という感じかな。
最後に
フリーランスの方々と協力してiOSアプリは実装を頑張った。
フルタイムは私だけだったので急な実装とかは結構頑張って1人で対応した。手伝ってくれたフリーランサーありがとうございました〜。
サーバサイドはまた時間があればそのときにどういう感じだったかとかは書きます。ポロッというと最後の2週間ぐらいは、私の後輩(学校が同じとかでは一切ない)がアルバイトとして参加してくれた。
今回は、メインロジックの解説を書いただけなので他の頑張った機能とかは羅列して終わりにしたい
- WebSocket を使ったメッセージやりとりができるよ
- WebRTC を使った 1-1 の通話ができるよ
- Firebase Dynamic Links を使った QR コードや招待リンクからの友達の招待
もしかしたら、今後ここに羅列した機能のやつも記事に書くかもしれない。
はい。というわけでこの時点で 14,780文字。疲れたので終わる。
もしこのアプリを何かしらの手段で知って使ってくれた人ありがとうございました。
ぴぺくんよ、永遠なれ
追記
先程この記事を Twitter にシェアしたらこのような言及を受けました。
調べてみたら iOS13 から VoIP にプライバシー規制がかかる*8 ということを知りました。
後付になってしまいましたがまず、 Pipe は iOS13 になってプライバシー規制が入るからサービスを終了させたわけではありません。
また、 Apple と戦ったのはこの記事の本文でも書いているのですが、 VoIP
の想定されていたと思われる(ここは私1人の意見です。 Apple はどういう意味合いでこの機能を iOS に実装したかは知らないです)本来の使い方ではないから戦ったのではなく、 PushKit
と CallKit
を同時に使わなければならないという成約を聞き出すのに時間を要したため 戦った
という表現を使用しました。 Apple からのリジェクト内容が参照できないので証拠として参照できる情報が少なくて残念。
上記 Slack のスクショが先述したビルド5で私がリジェクトに対して介入したときのやりとりです。
我々がこのような PushKit
の使い方をしなかった場合でも、将来的にプライバシー規制がかかるのでは?と感じています。
なので Twitter でも軽く言及したのですが我々の作ったアプリが原因で PushKit
にプライバシー規制がかかったんだ!のようなエビデンスのない決めつけはやめていただきたいと感じています。決して言及された方を責めているわけではございません。
また、 Apple 自体にはこのサービスは PushKit
の VoIP
を使ってスクリーンショットのみをサーバにアップロードするということをレビュー時に説明して、一切 PushKit
の VoIP
を使ってスクリーンショットのみを自社の管理するサーバにアップロードをするということに対して、否定的なコメントを頂いてはおりません。
戦うという表現は間違いだったとは思ったので謝罪させていただきます。そして、我々が今回挑戦したことに対し、避難するコメントも多々あるかとは思います。
Apple の言うままに実装できる範囲で実装しているので、もし Pipe が今後も引き続き運用が可能であったとしても、プライバシー規制に対して対応していく構えでした。断じて Apple に対して戦争を挑んでいるわけではなくあくまでもリリース作業と戦うために、俗に使われている戦うという表現を使用させていただきました。
付録
How to upload jpeg image using URLSession.
*1:小泉ひやかし🌻 on Twitter: "赤髪です… "
*2:ios - Pushkit VoiP push notifications wake up app execution time - Stack Overflow
*3:Facebookアプリがバッテリーを無駄に消費する原因だと発覚、バックグラウンド動作をオフにしても無音ファイル再生で無効化 - GIGAZINE
*4:https://community.zen.ly/hc/ja/articles/360000730688-%E3%83%90%E3%83%83%E3%83%86%E3%83%AA%E3%83%BC%E3%81%AE%E6%B8%9B%E3%82%8A%E3%81%8C%E6%97%A9%E3%81%84
*5:小泉ひやかし🌻 on Twitter: "繋がったがわからん… "
*6:小泉ひやかし🌻 on Twitter: "ようやくiOS同士でカメラとマイクからの入力を共有できるようになった… "