『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

所属の業務で作ったiOSアプリをサービス終了させたのでアプリのメインロジックの解説を書く

はじめに

こんにちは、久しぶりに髪の毛を派手髪にした直後にメイリッシュでバニーガール集団を見ながら書いている記事です。*1
なお、1日で書き終わることもなく無事後日持ち越しでオフィスで書いてます。

今回は、仕事で作った iOS アプリのサービスが終了したあとにメンバーから公開してもいいという了承をもらって(了承というか書いてほしいと言われた)、私もそろそろ新しい記事を書きたいなという感じになっていたのでアウトプット的に書きます!

この記事本当は、 iOSアプリが終了している状態でスクショをサーバにアップロードする仕組みはターゲット層に怖いと言われた というタイトルだったのでこれ以降、このタイトル前提で書かれてる部分があるので許してください!!

このアプリのターゲット層は 1,000% このブログ読まないと思うので、一番最後になぜ怖いと感じられてしまったのかと書きたい。

ちなみにこんな感じにサービスは幕を閉じた

先にサービスの紹介をさせてください

Pipe というスクショを親友間だけで共有するアプリでした。

差別化のポイント

  1. ユーザが自分自身で投稿をしなくても自動的に投稿される(投稿されて24時間したら見られなくなる)
  2. 友達以外には見られない
  3. トークスクショ(LINE や Twitter DM 、 Facebook Messenger 、 Instagram DM 等)のスクショは公開されない
  4. プッシュ通知をオフにしていても写真ライブラリへのアクセスとネットワークと電池があれば新しいスクショを自動でアップロードする

技術スタック及び環境等

  • iOSアプリ開発

  • サーバサイド

    • Django 2.2 (メインサーバサイド)
    • Flask 1.0.2 (メディア受け取り用サーバサイド)
      • Python 3.6.x (tensorflow-gpu が当時 3.7 で使えなかった気がする)
  • インフラ(AWS ほんのごく一部抜粋。)

    • EC2
    • RDS
    • S3
    • CloudFront
    • Lambda
    • ElastiCache (Redis)

一応こんな感じなことは書いてますけど、ぶっちゃけサーバサイドとインフラは機能するんであればなんでもいい。

サーバサイド系で必要になってくるコード類は、問答無用で Python なコードになってくるのでそこは許してください!!!

概要

PushKit というものを使用します。

developer.apple.com

以前 VoIP プッシュについて記事を書いてたりしてたんですが、そういうことでした。
ちなみに以下記事。

nnsnodnb.hatenablog.jp

ついでに、 APNSVoIP プッシュを Python で送信できるライブラリを作ったので Star ほしい

github.com

PushKit を使ってバックグラウンドで実行できる時間が 30秒 程度あるらしいです。ここは APNS プッシュ通知のバックグラウンドと同じらしいです。*2

PushKit の認証トークンは APNS のデバイストークンと違って、開発者側の任意のタイミングで取得できる & ユーザに対してプッシュ通知を送信しますのような表示を出さなくてもいい。というメリットがあります。詳しくは以下記事がいいかな

qiita.com

PushKit を選んだ理由

どうして PushKit にたどり着いたかというと、 Zenly というアプリ上の友達同士で位置情報を共有できる メンヘラ彼女が彼氏を監視するために作られたような アプリがありますね。ごめんなさい。許してください!なんでもしますから!!(なんでもするとは言っていない)

このアプリは、メンバー(私を含める)の実験結果、位置情報をオンにしてさえいれば、ネットワーク環境と電池があれば友達がアプリを開いてたら自動的に位置情報をサーバに送信しているということがわかりました。

はじめは、プッシュ通知のサイレントプッシュでもしてるんだろうなぁとか、位置情報が変わったら自動的に送信するような仕組みなんだろうなとか思ってたんですけど、私の知る限り自分がアプリを終了およびプッシュ通知を切っている状態、友達がアプリを開いているときにほぼリアルタイムに更新されるような仕組みは、 CoreLocationUserNotifications を使っても無理だった。

ちなみに CoreLocation でアプリが終了している状態でバックグランド処理をさせたい場合は

  • OS がよしなに位置情報が大きく動いたらイベント発火
  • 指定した位置情報に達したらイベント発火

前者の例をあげると、 Dropbox のカメラロールバックアップ機能。

そんで、先述通りの調査等を含めて丸2日間、 Google 先生に英語、中国語、ロシア語でお世話になりなんとか PushKit を使ったらこれと同じようなことができるのではないかという結論に至った。

本アプリでは、2分毎に AppleVoIP プッシュサーバに向けて全発射を行っている。

電池持ちはどうなの?

以前 Facebook が無音の音を無限に鳴らし続けていたとかいう事件*3があったと思う。それは電池消耗まじでヤバそうだと思った。

先述した Zenly というアプリ。こちらも以前電池消耗が内部で問題*4になったという話を聞いた。

本アプリでは、新しいスクショがあるかどうかを確認したらすぐに処理を止める(正しくは PKPushRegistryDelegatepushRegistry(_:didReceiveIncomingPushWith:for:completion:) メソッドの completion を呼ぶ)

1時間に 3% ぐらいの電池消耗なのでまぁぼちぼちかなといったところだった。スクショがアップロードされたり新しいスクショがなかったパターンでランダム的にテストを行った。
ここでのテストケースは1年以上普段使いした iPhoneX で行ったりしたので消耗は仕方ないっていう感じ。

PushKit を使ったアプリを実装した場合の実装デメリット

これはリジェクトされるまで知らなかったことなんですが CallKit を使った電話システムを実装しろっていう感じでした。
対策として、 WebRTC を使った通話機能を実装しました。

f:id:nanashinodonbee:20190821171036p:plain

初めてのリリースをするために Apple と戦った形跡です。
はじめのリジェクトで私が介入するべきだったとちょっとだけ後悔。ビルド5がリジェクトされた時点で私が介入して PushKit 使ってるんなら CallKit も使ってねということをようやく Apple のレビュアーさんから引き出すことに成功しました...
通話機能は本当は Asterisk を使おうかなって思ったんですが、ローカルで建てて端末同士でテストしてたのですが音声がうまくいかなかった*5 のと自分が今後これをちゃんとメンテできるかと言ったら難しいなと思ってやめて、 WebRTC を初めて実装しました。*6

あと、Apple の通常の想定動作とは違う処理をしているので、 PushKit でアプリがバックグラウンドのみで立ち上がっているので、 AppDelegateapplication(_: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 としています。

実装コード(抜粋等)

PythonPushKit の配信をする

さっき紹介したやつを使ってみます。

$ 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()
}

iOSURLSession はデフォルトで4つのコネクションを作る設定になっています。*7 なので同期的に1つずつアップロードしたい今回の場合はこのようなコードになりました。一応私が実装してテストしていた限りではちゃんとこれでできていたのでメモリリークとか以外は問題ないかと思います。もしこうやったらいいんじゃない?とかあったら教えてくださると勉強になります!!!

大体の実装はこんな感じになりました。 GCD の使い方がいまいち掴めてなくて難しい。ここらへんちゃんとできるようになりたい

なぜ怖いと感じられてしまったのか

  1. トークスクショが他人(アプリ内の友達)に見られてしまう
  2. 勝手にスクショがアップロードされてしまう

実際に聞き込みをしていない私の考えでは、上記2点なのではないかなって思っています。

トークスクショが他人(アプリ内の友達)に見られてしまう

これに関しては、プロダクトモデルとして トークスクショは他の誰からも見られないよ! という実装モデルになっていました。それもウォークスルーやチュートリアル動画や利用規約にも記述がある。
でも、そんなのJKが読む?読まないよね。(読んでくれた人はありがとう!)
ハイ次!!!

勝手にスクショがアップロードされてしまう

投稿いらずでシステムが自動的にスクショをアップロードするよ!
実際の説明はこんな簡易的ではなかったと思うんだけどここではもうこの説明でいいよね?
新しい試みだと思うですが、言い返せば スクショ撮ったら全部勝手にアップロードするよ ということになるわけだ。
我々のブランドライフビジョンは
「ありのままのつながり」を忘れさせない
という感じのものなわけだったが、実際のエンドユーザはそんなビジョン知らんわけでどうやってこれを伝えるかというのも内部で議論されてたりした。私は難しいことはわからないのであんまり参加してない。

という感じかな。

最後に

フリーランスの方々と協力してiOSアプリは実装を頑張った。
フルタイムは私だけだったので急な実装とかは結構頑張って1人で対応した。手伝ってくれたフリーランサーありがとうございました〜。
サーバサイドはまた時間があればそのときにどういう感じだったかとかは書きます。ポロッというと最後の2週間ぐらいは、私の後輩(学校が同じとかでは一切ない)がアルバイトとして参加してくれた。

今回は、メインロジックの解説を書いただけなので他の頑張った機能とかは羅列して終わりにしたい

  • WebSocket を使ったメッセージやりとりができるよ
  • WebRTC を使った 1-1 の通話ができるよ
  • Firebase Dynamic Links を使った QR コードや招待リンクからの友達の招待

もしかしたら、今後ここに羅列した機能のやつも記事に書くかもしれない。

はい。というわけでこの時点で 14,780文字。疲れたので終わる。

もしこのアプリを何かしらの手段で知って使ってくれた人ありがとうございました。

ぴぺくんよ、永遠なれ

ぴぺくん最期の言葉 (by 勝手に編集したやつ自分)

追記

f:id:nanashinodonbee:20190822221200p:plain

先程この記事を Twitter にシェアしたらこのような言及を受けました。
調べてみたら iOS13 から VoIP にプライバシー規制がかかる*8 ということを知りました。
後付になってしまいましたがまず、 Pipe は iOS13 になってプライバシー規制が入るからサービスを終了させたわけではありません。

また、 Apple と戦ったのはこの記事の本文でも書いているのですが、 VoIP の想定されていたと思われる(ここは私1人の意見です。 Apple はどういう意味合いでこの機能を iOS に実装したかは知らないです)本来の使い方ではないから戦ったのではなく、 PushKitCallKit を同時に使わなければならないという成約を聞き出すのに時間を要したため 戦った という表現を使用しました。 Apple からのリジェクト内容が参照できないので証拠として参照できる情報が少なくて残念。

f:id:nanashinodonbee:20190822221051p:plain

上記 Slack のスクショが先述したビルド5で私がリジェクトに対して介入したときのやりとりです。

我々がこのような PushKit の使い方をしなかった場合でも、将来的にプライバシー規制がかかるのでは?と感じています。
なので Twitter でも軽く言及したのですが我々の作ったアプリが原因で PushKit にプライバシー規制がかかったんだ!のようなエビデンスのない決めつけはやめていただきたいと感じています。決して言及された方を責めているわけではございません。

また、 Apple 自体にはこのサービスは PushKitVoIP を使ってスクリーンショットのみをサーバにアップロードするということをレビュー時に説明して、一切 PushKitVoIP を使ってスクリーンショットのみを自社の管理するサーバにアップロードをするということに対して、否定的なコメントを頂いてはおりません。
戦うという表現は間違いだったとは思ったので謝罪させていただきます。そして、我々が今回挑戦したことに対し、避難するコメントも多々あるかとは思います。
Apple の言うままに実装できる範囲で実装しているので、もし Pipe が今後も引き続き運用が可能であったとしても、プライバシー規制に対して対応していく構えでした。断じて Apple に対して戦争を挑んでいるわけではなくあくまでもリリース作業と戦うために、俗に使われている戦うという表現を使用させていただきました。

付録

How to upload jpeg image using URLSession.