20191020のiOSに関する記事は17件です。

[iOS] App ID Prefixについて

概要

アプリのApp ID Prefixを変更したい場面が出てきて色々調査したため、その内容を整理しておきます。以下の内容が不明な方向けです。

  • App ID PrefixとTeam ID、Bundle IDの関係性
  • 新規アプリ作成時にApp ID Prefixが複数存在する場合、どれを選ぶべきか
  • App ID Prefixは変更可能か

※本記事の情報は個人で確認したものであり、情報の正確性を保証するものではありません。App IDの仕様についても今後変更される可能性があります。

App IDの構成

App IDはApp ID Prefixと呼ばれる、大文字英数10桁の識別子Bundle ID(Bundle Identifier, App ID Suffix)と呼ばれる識別子で構成されています。

<App ID Prefix>.<Bundle ID>

例) ABCDE12345.jp.hogehoge.hogeapp

App IDは開発者を識別するためのものであり、他の開発者とは別のものが割り当てられます。基本的にはTeam IDと呼ばれるデフォルトのApp ID Prefixのみですが、過去にApp ID Prefixを開発者が発行できた経緯があり、開発者によっては複数のApp ID Prefixが存在します。

Bundle IDはアプリをユニークに識別するためのもので、このIDが異なる場合は別アプリとして認識されます。

新規にアプリを作成し、App IDを発行する際には、Team IDを含めたこれらApp ID Prefixから選択することとなります。

App ID PrefixはKeychainでのデータ共有機能に影響する

App ID Prefixを共有するアプリ間ではKeychainを介してデータの共有が可能です。逆を言えば、App ID Prefixを変更すると、もとのKeychainデータにアクセスすることはできなくなりますので注意が必要です。
他にも、App ID Prefixを共有していないアプリ間ではKeychainを介してデータの共有ができないため、新規アプリ作成時には、App ID PrefixをデフォルトのTeam IDに揃えておく、などの考慮が必要です。

App ID Prefixは変更できるか 

  • Bundle IDがワイルドカードを利用している場合、App ID Prefixのみを変更した同じ形式のBundle IDを作成することで、新しいApp ID Prefixのものへ移行可
  • Bundle IDがワイルドカードを利用していない場合(explicit)
    • アプリのバイナリをAppStoreConnectへアップロードする前であれば、該当のApp IDを削除し、新しいApp ID Prefixを指定し、再度作成することで移行可
    • アプリのバイナリをAppStoreConnectへアップロードした後であれば、以下のエラーがでてApp IDが削除できないため、移行不可

There is a problem with the request entity
The App ID '削除しようとしたAppID' appears to be in use by the App Store, so it can not be removed at this time.

なお、アプリの配信先の国をすべて削除する、開発者アカウントのAdmin権限でAppStoreConnect上でアプリを削除するなどを行なっても上記のエラーは解消できませんでした。

ただし、Appleに依頼すれば変更できる可能性も

Appleの開発者サポートに確認したところ、デベロッパーアカウントのアカウントホルダーからの依頼があれば、Appleの内部チームがその内容を審査し、受け付けてくれれば変更を実施してくれることもあるようです。(リードタイムは明確になってはおりませんが、少なくとも数日は要するようです)

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iosアプリのアプリ内課金テスト(sandbox )で経験したおかしなこと

iOSの課金テスト、特に定期購読サービスのテストは結構不便なところがあります。

その上に、sandboxの課金環境はとても不安定で、テストのときによく躓くことがありました。

何個か書いてみたいと思います。

sandboxユーザーを切り替えることが出来ない

ios13 の問題ですが、iosは2つのアカウントでログインすることが出来ます。

普通にApp storeで利用しているアカウントでログインすることが出来ますが、

課金のテスト用で利用出来るsandboxのアカウントでもログインすることが出来ます。

sandboxのアカウントに一度ログインしてしまうと、二度とログアウトすることが出来ないのです・・・。_

これが結構テストのときに不便で、ログアウトする方法は iOS13.1にアップデートするか、端末の設定のところから端末をリセットする事です。

課金すると、端末には課金成功と表示されるが、実際のレスポンスはエラーが返却される・・・

これは結構厄介です。

テスターから、appleの課金は成功したのに、課金に失敗したと表示されますと報告が・・・。

ケーブルをつないでdebugしてみると、レスポンスがエラーが返却されるのに、エラーメッセージは 完了しました という文言が返却されました。

https://forums.developer.apple.com/thread/121096

これは他にも同じ症状の人がいたので、それからは調べることをやめました。

App Storeにアクセスできない・・・

これもどうしようか迷った、結構厄介な問題でした。

ストアからダウンロードしたものは何も問題ないのに、testflightからダウンロードしたものは課金アイテムの情報の取得も、購入も出来ませんでした。

エラーはこんな感じでした。

Error Domain=AMSErrorDomain Code=301 "Invalid Status Code" UserInfo={AMSStatusCode=503, 
NSLocalizedDescription=Invalid Status Code, 
NSLocalizedFailureReason=The response has an invalid status code}

301だったり、503だったりしたのでサーバーの問題か・・・?

でもappleのsystem statusをみても今は問題ないと表示されている

こちらですが、 DNS設定に 8.8.8.8と8.8.4.4 を追加したら課金周りのテストができるようになりました。

https://stackoverflow.com/questions/58443064/how-to-handle-skproductsrequest-301-invalid-status-code

こちらのstackoverflowに助けられました・・・。

単純に課金ができない

上記の理由でなくても、単純にsandbox環境は課金ができなくなるときがあります。

これは時間がすぎれば症状がなおるので、少し時間を空けてからテストすることにしましょう・・・。


他にもsandboxの問題を経験した方がいらっしゃれば、ぜひコメントお願いします!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS13のモーダルはFullscreenもいいけどCurrentContextも検討してみて

iOS13からモーダルを表示するときはセミモーダルがデフォルトの挙動となりました。
スクリーンショット 2019-10-20 20.02.06.png
完全なフルスクリーンではなく後ろのViewが少し見えるようなデザインで、下にスワイプで元のViewに戻ることができます。

これをされると困るデザインがあります。例えばログイン後に表示されるViewはセミモーダルではなくフルスクリーンで表示したいでしょう。

StoryboardからPresentationをFullscreenにすることで次のモーダルを完全なフルスクリーン状態で遷移できます。
スクリーンショット 2019-10-20 20.06.52.png

Swiftの場合はこうします。

let vc = ViewController()
vc.modalPresentationStyle = .fullScreen
present(vc, animated: true)

スクリーンショット 2019-10-20 20.09.27.png
この状態であればスワイプで戻ることもできません。
ここまでであればあまり問題はなさそうに見えます。

さらにModalで遷移したいとき

ここからさらにモーダルを表示したいとします。このときはセミモーダルのほうが利便性があがるので普通の遷移にします。
するとどうでしょう。

スクリーンショット 2019-10-20 20.12.18.png
なんと後ろのViewが下がらないままセミモーダルが出てしまいました。

iOS13においてfullscrrenでモーダル表示したものはずっとfullscreenの状態を維持するのでこのような動きになるようです。
でもこれじゃかっこよくない!下がってほしい!というときはCurrent Contextを使ってみます。

先ほどのFullscrrenからCurrent Contextに変更します。するとStoryboard上では灰色はフルスクリーンで、その次のモーダルでは灰色Viewが下にずれているのが確認できます。
(よく見ると上のStoryboardでもFullscreen選択時のモーダルの見え方はシミュレートされていましたね)
スクリーンショット 2019-10-20 20.15.10.png

ではこれで解決!かと思いきや実際実行してみると・・・

スクリーンショット 2019-10-20 20.17.49.png
なんと今度はステータスバーが全く見えなくなってしまいました。

CurrentContext時にStatusBarを見えるようにする

これは推測ですが、CurrentContextによって諸々の状態を参照する先が灰色のViewになっているのだと思われます。灰色Viewの時点ではステータスバーは黒色テキストなので、モーダル遷移後も灰色Viewからステータスバーの情報を取得していると考えると、黒の背景に黒のテキストとなってしまい見えなくなってしまったものと考えられます。

これを回避するにはViewController毎にステータスバーの色は何色にすべきかという情報を設定します。

灰色のViewControllerにpreferredStatusBarStyleプロパティをオーバーライドします。

class ViewController: UIViewController {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        if presentedViewController != nil {
            return .lightContent
        } else {
            return super.preferredStatusBarStyle
        }
    }
}

presentedViewControllerは現在のViewController(灰色)からみてさらにモーダル表示しているViewControllerがあればそのViewControllerが、なければnilが返ってきます。
つまりpresentedViewControllerがnilでないなら何かしらモーダルを表示しているということです。
このときはステータスバーを白とし、それ以外はデフォルトの挙動としました。
ここを拡張することでもう少し細かな要望にも応えることができます。例えば次のModalもフルスクリーンだったらデフォルト挙動にするとかですね。

青色のViewControllerのpreferredStatusBarStyleは参照されませんでした。これもCurrentContextが影響していると考えられます。

これでめでたくセミモーダル時にステータスバーが白になりました。
スクリーンショット 2019-10-20 20.22.55.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS13のモーダルはFullscreenもいいけどCurrentContextも検討したい

iOS13からモーダルを表示するときはセミモーダルがデフォルトの挙動となりました。
スクリーンショット 2019-10-20 20.02.06.png
完全なフルスクリーンではなく後ろのViewが少し見えるようなデザインで、下にスワイプで前のViewに戻ることができます。

これをされると困るデザインがあります。例えばログイン後に表示されるViewはセミモーダルではなくフルスクリーンで表示したいでしょう。

StoryboardからPresentationをFullscreenにすることで次のモーダルを完全なフルスクリーン状態で遷移できます。
スクリーンショット 2019-10-20 20.06.52.png

Swiftの場合はこうします。

let vc = ViewController()
vc.modalPresentationStyle = .fullScreen
present(vc, animated: true)

スクリーンショット 2019-10-20 20.09.27.png
この状態であればスワイプで戻ることもできません。
ここまでであればあまり問題はなさそうに見えます。

さらにModalで遷移したいとき

ここからさらにモーダルを表示したいとします。このときはセミモーダルのほうが利便性があがるので普通の遷移にします。
するとどうでしょう。

スクリーンショット 2019-10-20 20.12.18.png
なんと後ろのViewが下がらないままセミモーダルが出てしまいました。

iOS13においてfullscrrenでモーダル表示したものはずっとfullscreenの状態を維持するのでこのような動きになるようです。
でもこれじゃかっこよくない!下がってほしい!というときはCurrent Contextを使ってみます。

先ほどのFullscrrenからCurrent Contextに変更します。するとStoryboard上では灰色はフルスクリーンで、その次のモーダルでは灰色Viewが下にずれているのが確認できます。
(よく見ると上のStoryboardでもFullscreen選択時のモーダルの見え方はシミュレートされていましたね)
スクリーンショット 2019-10-20 20.15.10.png

ではこれで解決!かと思いきや実際実行してみると・・・

スクリーンショット 2019-10-20 20.17.49.png
なんと今度はステータスバーが全く見えなくなってしまいました。

CurrentContext時にStatusBarを見えるようにする

これは推測ですが、CurrentContextによって諸々の状態を参照する先が灰色のViewになっているのだと思われます。灰色Viewの時点ではステータスバーは黒色テキストなので、モーダル遷移後も灰色Viewからステータスバーの情報を取得していると考えると、黒の背景に黒のテキストとなってしまい見えなくなってしまったものと考えられます。

これを回避するにはViewController毎にステータスバーの色は何色にすべきかという情報を設定します。

灰色のViewControllerにpreferredStatusBarStyleプロパティをオーバーライドします。

class ViewController: UIViewController {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        if presentedViewController != nil {
            return .lightContent
        } else {
            return super.preferredStatusBarStyle
        }
    }
}

presentedViewControllerは現在のViewController(灰色)からみてさらにモーダル表示しているViewControllerがあればそのViewControllerが、なければnilが返ってきます。
つまりpresentedViewControllerがnilでないなら何かしらモーダルを表示しているということです。
このときはステータスバーを白とし、それ以外はデフォルトの挙動としました。
ここを拡張することでもう少し細かな要望にも応えることができます。例えば次のModalもフルスクリーンだったらデフォルト挙動にするとかですね。

青色のViewControllerのpreferredStatusBarStyleは参照されませんでした。これもCurrentContextが影響していると考えられます。

これでめでたくセミモーダル時にステータスバーが白になりました。
スクリーンショット 2019-10-20 20.22.55.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【swifter】Twitter連携アプリを作るためのswifterの主な関数の使い方の記録

Swifterとは

swiftで書かれたTwitter Framework.
ソースコードも公開されているため、使いやすい.
TWitterKitがサポート終了したし、こっち使えばいいんじゃね。

ソースコード : github : Swifter

Swifterに用意された主な関数の使い方

連携する

    //login処理
    // swifter構造体の宣言
    let swifter = Swifter(consumerKey: <twitter consumer key>, consumerSecret: <twitter consumer secret>)
    // のちに使う変数
    var twitterTokenKey:String!
    var twitterTokenSecret:String!

    swifter.authorize(
            withCallback: URL(string: "swifter-<twitter consumer key>://")!,
            presentingFrom: self,
            success: { accessToken, response in
                print(response)
                guard let accessToken = accessToken else { return }
                twitterTokenKey = accessToken.key
                twitterTokenSecret = accessToken.secret
            }, failure: { error in
                print(error)
            }
        )

tweetする

返り値はjsonデータ形式

    // さっきゲットしたtwitterTokenKeyとtwitterTokenSecretを使ってswifterを宣言し直す
    swifter = Swifter(consumerKey: <twitter consumer key>, consumerSecret: <twitter consumer secret>, oauthToken: twitterTokenKey, oauthTokenSecret: twitterTokenSecret)

    swifter.postTweet(status: "Tweetの内容", success: { json in
            // 成功時の処理
            print(json)
        }, failure: { error in
            // 失敗時の処理
            print(error)
        })

timelineを取得する

返り値はjsonデータ形式

        // こっちは、連携中のユーザーのフォローしてるユーザーのツイートを時系列順に。
        swifter.getHomeTimeline(count: 50,success: { json in
            // 成功時の処理
            print(json)
        }, failure: { error in
            // 失敗時の処理
            print(error)
        })

        // こっちは、@~~~で指定したユーザーのツイートを時系列順に取得。        
        swifter.getTimeline(for: .screenName("<twitterの@~~~のuser名の~~~の部分>"),success: { json in
            // 成功時の処理
            print(json)
        }, failure: { error in
            // 失敗時の処理
            print(error)
        })

まとめ

他にも、twitter上での操作はコードを通して全部できるようになってるっぽかったから、ソースコード見て必要な機能があったら実装していこう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iphoneだけでPythonistaでyoutubeから動画をダウンロード出来るようにしよう!

概要

みなさんこんにちは。
Pytohnista3を触り始めてからの僕の念願が一つ達成されました。
ずばりYoutubeの動画をiphoneからダウンロードするです!

参考にしたサイト

実際やったこと、ソース

  • pythonistaの中にフォルダを作ります。
  • 作ったフォルダの中に「DL_videos」というフォルダを作成します。ダウンロードした動画はこのフォルダに保存されます。
  • 以下のソースを作成します。ソースは少し多いですが頑張って作ってみてください。

①byte2si.py

byte2si.py
import math
def main(byte):
    #byteの桁数を取得して、3桁刻みに単位を変えてくだけ
    if 0 <= byte:
        dig = int(math.log10(byte))
        if 0 <= dig < 3:
            bytes = byte / pow(10, 0)
            return bytes, 'B'
        elif 3 <= dig < 6:
            bytes = byte / pow(10, 3)
            return bytes, 'KB'
        elif 6 <= dig < 9:
            bytes = byte / pow(10, 6)
            return bytes, 'MB'
        elif 9 <= dig < 12:
            bytes = byte / pow(10, 9)
            return bytes, 'GB'
        elif 12 <= dig <= 15:
            bytes = byte / pow(10, 12)
            return bytes, 'TB'
    else:
        raise ValueError('Not num')


def byte2si(byte):
    num, unit = main(byte)
    #少数第2位まで丸める
    renum = round(num, 2)
    return str(renum) + unit

②main.py

main.py
from pytube import YouTube
#import add_video
import my_module
import byte2si
import url_get
import os
import glob
import time
import sys
import youtube_dl

VIDEO_DIR = os.path.join(os.getcwd(), "DL_videos")
OPTS = {
    "outtmpl": "{VIDEO_DIR}/%(title)s.mp4".format(VIDEO_DIR=VIDEO_DIR),
    'ignoreerrors': True
}


def download(url):
    """
    Download video from YouTube.

    Parameters
    ----------
    url : str
        YouTube video URL

    Returns
    ----------
    info : dict
        Downloaded video info.
    """
    print("Downloading {url} start..".format(url=url))
    with youtube_dl.YoutubeDL(OPTS) as y:
        info = y.extract_info(url, download=True)
        print("Downloading {url} finish!".format(url=url))
    return info

def rename(info):
    """
    Rename downloaded video filename as camelcase.

    Parameters
    ----------
    info : dict
        Downloaded video info.
    """
    title = info["title"]
    pattern = '{VIDEO_DIR}/{title}.mp4'.format(VIDEO_DIR=VIDEO_DIR, title=title)
    for v in glob.glob(pattern, recursive=True):
        print("{title}.mp4 found! Renaming start..".format(title=title))
        file_path = os.path.join(VIDEO_DIR, v)
        new_file_path = file_path.replace(' ', '_')
        os.rename(file_path, new_file_path)
        print("Renaming finish!".format(title))

def absfpath(path):
    # ”Download”ディレクトリ下のファイルを取得する
    flist = glob.glob(path + '/*')
    if flist:
        # “Download”ディレクトリ下の1つしかない動画ファイルを取得する
        fpath = os.path.abspath(flist[0])
         # “Download”ディレクトリ下にある動画ファイルの絶対パスを返す
        return fpath
    else:
        raise OSError('The file does not exist.')


def main():
    #プログレスバー
    print('Now executing...')

    # YouTubeアプリからurlを取得
    url = url_get.yturl()
    if not url:
        raise ('Not YouTube')

    print(url)
    info = download(url)
    rename(info)
    abspath = absfpath(VIDEO_DIR)


if __name__ == '__main__':
    # 開始時間を取得
    start = time.time()
    main()
    #終了時間を少数第3位まで取得
    finish = round(time.time() - start, 3)
    #実行所要時間を表示
    print('Execute Time:', finish)
    #終了
    print('Done')

③url_get.py

url_get.py
import appex
import re


def trns():
    if not appex.is_running_extension():
        raise RuntimeError(
            'This script is intended to be run from the sharing extension.')

    url = appex.get_text()
    if not url:
        raise ValueError('No input text found.')
    elif re.search(r'youtube', url):
        return url
    raise ValueError('Not YouTube')


def yturl():
    geturl = trns()
    # 取得したurlの余分なテキストを置換し、なくす
    url = geturl.replace('&feature=share', '')
    return url


if __name__ == '__main__':
    print(yturl())
  • main.pyをSafariやyoutubeの共有→「Run Pythonista Script」の中で実行できるよう登録します。

使い方

  • Youtubeを開きます。
  • 見たい動画またはプレイリストに行きます。
  • 「共有」から「Run Pythonista Script」を選び登録したスクリプトを実行します。
    • 動画の場合:動画がダウンロードされDL_Videosフォルダの中に保存されます。
    • プレイリストの場合: プレイリスト内全ての動画がダウンロードされDL_Videosフォルダの中に保存されます(プレイリストは公開のものしかダウンロードできないのでご注意!)。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】TelloとUDP通信②- Telloからメッセージを受信する -

概要

前回、Telloに対しUDP通信でコマンドを送信しましたが、今回はコマンドに対しての応答について説明していきます。
各コマンドに対し、Telloから応答メッセージが送信されるため、端末側で受信処理を行うことでメッセージを取得できます。

受信処理

Telloにコマンドを送信した時、Telloから応答メッセージが送信されます。
NWConnection の receive を使用することで、受信処理を行えます。

let connection = NWConnection(host: "192.168.10.1", port: 8889, using: .udp)

//送信処理
connection.send(content: message, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed( error in ))

//受信処理
connection.receive(minimumIncompleteLength: 0, maximumLength: Int(Int32.max)) { (data: Data?, contentContext: NWConnection.ContentContext?, aBool: Bool, error: NWError?) in
         //処理内容           
}

取得したdataは文字に変換することで内容を確認できます。

if let data = data, let message = String(data: data, encoding: .utf8) {
         //応答メッセージ
         print("message: \(message)")
}

応答メッセージ

Tello SDKのコマンドでは、離陸、着陸等の操作に対しての応答と、バッテリー等のステータス情報に対しての応答があります。

操作コマンドは以下になります。応答としてはokかerrorを送信します。

コマンド 説明
command SDKモード開始
takeoff 離陸
land 着地
streamon ビデオストリーム開始
streamoff ビデオストリーム停止
emergency 緊急停止
up x 上にxcm進む
x:20-500
down x 下にxcm進む
x:20-500
left x 左にxcm進む
x:20-500
right x 右にxcm進む
x:20-500
forward x 前にxcm進む
x:20-500
back x 後ろにxcm進む
x:20-500
cw x 時計回りにx度回転
x: 1-3600
ccw x 反時計回りにx度回転
x: 1-3600
flip x xにひっくり返す
X:l (左) r (右) f (前) b (後ろ)
go x y z speed x y zにspeed(cm/s)で進む
x: 20-500 y: 20-500 z: 20-500 speed: 10-100
curve x1 y1 z1 x2 y2 z2 speed 現在の座標と2つの与えられた座標で定義された曲線をspeed(cm/s)で飛行
x1, x2: 20-500
y1, y2: 20-500
z1, z2: 20-500
speed: 10-60

Telloのステータス情報は以下のコマンドで確認できます。

コマンド 説明 応答
speed? 速度 x:1-100 (cm/s)
battery? バッテリー x:0-100
time? 飛行時間 x (s)
height? 高さ x:0-3000 (cm)
temp? 温度 x:0-90 (℃)
attitude? IMU姿勢データ pitch roll yaw
baro? 気圧 x (m)
acceleration? IMU角加速度データ x y z (0.001g)
tof? TOFから距離値 x:30-1000 (cm)
wifi? Wi-Fi SNR SNR

動画情報の取得

Telloのカメラからの情報は stremon のコマンドで動画情報を受信できるようになります。
取得する場合、PORT:8889からは取得できないため、IP:0.0.0.0 PORT:11111 のUDPサーバを作り受信させる必要があります。

参考

receive
Tello SDK

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode11で外部ディスプレイに表示できるようにする方法

はじめに

iPhone/iPadとApple Lightning - Digital AVアダプタを用いていることでHDMIモニター(TV)に映像を出力することができます。
通常はミラーリングとなりますが、本方法を用いると液晶(OLED)とHDMIで別の表示することができます。
例えば、iPhoneの液晶(OLED)を操作画面として、HDMIを表示画面にするなどできます。

Xcode11で外部ディスプレイに表示する方法を試行錯誤したので投稿します。
まだわかっていないところがあり、コメントいただければと思います。

実装

いくつかのサイトを参考に実装しました。
最後に参考サイトのリンクしますので確認してみてください。
また、サンプルプロジェクトをgithubに公開していますので参考にしてください。

UISceneを無効化

Xcode11からUIScreenからUISceneに変わっています。しかしながら新しいUISceneで外部ディスプレイ対応する方法がわかりませんでした?
なのでUISceneを無効化します。

githubのサンプルプロジェクトのcommitログを参考にしてください。
概要は下記の通りです。
* AppDelegate.swiftのUISceneに関連するメソッドを無効化(削除でも可)
* SceneDelegate.swiftの全てのメソッドを無効化(削除でも可)
* AppDelegate.swiftvar window: UIWindow?を追加
* Info.plistのUISceneに関する記述を削除

外部ディスプレイの接続切断通知を受け取れるように設定

接続切断通知を受け取れるようにします。

通知設定
    /// 外部ディスプレイの接続と切断の通知を受け取れるようにする
    private func setupNotification() {
        let center = NotificationCenter.default

        center.addObserver(self, selector: #selector(externalScreenDidConnect(notification:)), name: UIScreen.didConnectNotification, object: nil)

        center.addObserver(self, selector: #selector(externalScreenDidDisconnect(notification:)), name: UIScreen.didDisconnectNotification, object: nil)
    }

接続処理を実装

Storyboardで作成した画面にStoryboardIDを設定しておき接続時に画面を生成します。
必要に応じて外部から呼び出せるようにインスタンスを保持しておくと便利だと思います。

接続処理
    /// 外部ディスプレイを接続されたときに処理する
    /// - Parameter notification:接続されたUIScreenインスタンス
    @objc func externalScreenDidConnect(notification: NSNotification) {
        guard let screen = notification.object as? UIScreen else { return }
        guard externalWindow == nil else { return }
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "ExternalDisplayViewController") as? ExternalDisplayViewController else { return }

        externalWindow = UIWindow(frame: screen.bounds)
        externalWindow?.rootViewController = viewController
        //iOS13.0からDeprecatedになっているがUISceneを無効化しているのでこちらのプロパティーを設定する
        externalWindow?.screen = screen
        externalWindow?.isHidden = false

        //外部ディスプレイのViewControllerをあとで操作できるようにインスタンスを保持する
        externalDisplayViewController = viewController
    }

切断処理を実装

外部ディスプレイが切断時の処理も記述します。

切断処理
    /// 外部ディスプレイを切断されたときに処理する
    /// - Parameter notification: 切断されたUIScreenインスタンス
    @objc func externalScreenDidDisconnect(notification: NSNotification) {
        guard let screen = notification.object as? UIScreen else { return }
        guard let _externalWindow = externalWindow else { return }

        //切断通知されたscreenと現在表示中のscreenが同じかチェックする
        if screen == _externalWindow.screen {
            _externalWindow.isHidden = true
            externalWindow = nil
            //外部ディスプレイのViewControllerを削除する
            externalDisplayViewController = nil
        }
    }

最後に

他の記事を参考に作成しましたが、externalWindow?.screenするとDeprecatedになっているプロパティで警告されてしまいます。
正しい実装方法を調べてみましたが、わかりませんでした。
コメントをいただければと思います。
(あわせてこの記事とサンプルプロジェクトを変更していきたいと思います。)

参考サイト

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift5.1】DuctTapeで初期化時の値の代入をもっと簡潔にする

はじめに

Swiftでオブジェクトのインスタンス化をしつつpropertyに値を代入する場合は、以下のような実装になるかと思います。

let label: UILabel = {
    let label = UILabel()
    label.numberOfLines = 0
    label.textColor = .red
    label.text = "Hello, World!!"
    return label
}()

上記と同じ状態のインスタンスを、メソッドチェーンで実現する方法を紹介しようと思います。

DuctTapeを利用する

DuctTapeというOSSを利用することで、先程の実装を以下のような実装にできます。

let label = UILabel().ductTape
    .numberOfLines(0)
    .textColor(.red)
    .text("Hello, World!!")
    .build()

NSObjectを継承したオブジェクトのインスタンスの.ductTapeにアクセスすると、Builderが返ってきます。
値を代入したいpropertyがある場合、そのproperty名と同一名のclosureにアクセスできるので、closureの引数として代入したい値を渡します。
一通りの値の設定が終わったら.build()を呼び出すことで、任意の値が代入された状態のインスタンスが返されます。

メソッドにアクセスする場合

インスタンスのメソッドにアクセスしたい場合は、Builderの.reinforceを介してインスタンスにアクセス
できるようになるため、渡されたインスタンスからメソッドにアクセスします。

let collectionView = UICollectionView().ductTape
    .backgroundColor(.red)
    .reinforce { $0.register(UITableViewCell.self, forCellWithReuseIdentifier: "Cell") }
    .build()

NSObjectを継承していないオブジェクトでも利用する

以下のように、Builderのinitializerの引数にインスタンスを渡すことで利用が可能になります。

class Dog {
    var name: String = ""
}

let dog = Builder(Dog())
    .name("Copernicus")
    .build()

DuctTapeの仕組み

Swift5.1から利用できるKeyPath dynamicMemberLookupを利用して実装してします。
そのためpropertyごとに代入用の処理を実装する必要はなく、subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<Base, Value>) -> (Value) -> Builder<Base>を実装してしまえば、返り値のclosureの引数で受け取った値をKeyPathを介してインスタンスに代入する処理を実現できます。
また、KeyPath dynamicMemberLookupによる実装のため、該当のproperty名が自動補完されます。

@dynamicMemberLookup
public struct Builder<Base: AnyObject> {

    private let _build: () -> Base

    public init(_ build: @escaping () -> Base) {
        self._build = build
    }

    public init(_ base: Base) {
        self._build = { base }
    }

    public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<Base, Value>) -> (Value) -> Builder<Base> {
        { [build = _build] value in
            Builder {
                let object = build()
                object[keyPath: keyPath] = value
                return object
            }
        }
    }

    public func build() -> Base {
        _build()
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode11でiOS13以前の端末で実行できるようにする方法

はじめに

Xcode11はSwiftUIやiPadOS向けのマルチタスク強化などあり、環境変化があると思います。
今回は、アプリの開発要件がiOS12以降などの場合にどうすればいいのかを手順書みたいにまとめます。

実際にやってみる

今回は、iOS12.0以降にする前提で進めます。
githubにサンプルプロジェクトをアップしていますのであわせて参考にしてください。
(手順ごとにコミットを分けています。)

手順1 iOS Develoyment Targetを変更する

プロジェクト設定のInfoタブより iOS Develoyment Target の項目を12.0にします。

スクリーンショット 2019-10-20 13.37.05.png

手順2 ビルドエラー箇所を修正する

iOS13以前で非サポートメソッドが全てエラーとなります。
@available(iOS 13.0, *)をつけてビルドエラーを解決します。
詳しい変更箇所は、githubのcommitログ を参考にしてください。

【エラー画面】
スクリーンショット 2019-10-20 13.40.35.png

【修正後】
スクリーンショット 2019-10-20 14.03.11.png

手順3 デバックウィンドに表示されるエラーを解決する

ここまでで一見大丈夫そうなのですが、iOS12上で実行すると画面は真っ暗でなにも表示されません。
デバッグウィンドには [Application] The app delegate must implement the window property if it wants to use a main storyboard file. と表示されています。

【デバッグウィンドの表示例】
スクリーンショット 2019-10-20 13.48.57.png

これは、storyboardファイルがうまく読み込めていないようです。
AppDelegate.swiftvar window: UIWindow? を追加することで解決できます。

var window: UIWindow?を追加した例】
スクリーンショット 2019-10-20 14.07.46.png

以上で終わりです。

最後に

他の方も記載されていますが、いまいちよく分からなかったので自分なりにまとめました。
参考にした。記事のリンクを記載させていただきます。

参考記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode11で新規作成したプロジェクトをiOS13以前の端末で実行できるようにする方法

はじめに

Xcode11はSwiftUIやiPadOS向けのマルチタスク強化などあり、環境変化があると思います。
今回は、アプリの開発要件がiOS12以降などの場合にどうすればいいのかを手順書みたいにまとめます。

実際にやってみる

今回は、iOS12.0以降にする前提で進めます。
githubにサンプルプロジェクトをアップしていますのであわせて参考にしてください。
(手順ごとにコミットを分けています。)

手順1 iOS Develoyment Targetを変更する

プロジェクト設定のInfoタブより iOS Develoyment Target の項目を12.0にします。

スクリーンショット 2019-10-20 13.37.05.png

手順2 ビルドエラー箇所を修正する

iOS13以前で非サポートメソッドが全てエラーとなります。
@available(iOS 13.0, *)をつけてビルドエラーを解決します。
詳しい変更箇所は、githubのcommitログ を参考にしてください。

【エラー画面】
スクリーンショット 2019-10-20 13.40.35.png

【修正後】
スクリーンショット 2019-10-20 14.03.11.png

手順3 デバックウィンドに表示されるエラーを解決する

ここまでで一見大丈夫そうなのですが、iOS12上で実行すると画面は真っ暗でなにも表示されません。
デバッグウィンドには [Application] The app delegate must implement the window property if it wants to use a main storyboard file. と表示されています。

【デバッグウィンドの表示例】
スクリーンショット 2019-10-20 13.48.57.png

これは、storyboardファイルがうまく読み込めていないようです。
AppDelegate.swiftvar window: UIWindow? を追加することで解決できます。

var window: UIWindow?を追加した例】
スクリーンショット 2019-10-20 14.07.46.png

以上で終わりです。

最後に

他の方も記載されていますが、いまいちよく分からなかったので自分なりにまとめました。
参考にした。記事のリンクを記載させていただきます。

参考記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

IOS develop!IOS 開発

—VietNamese—
Xin chào mọi người, hiện em/mình đang học về ios developer qua swift, thông qua một số khoá học online.
Udemy: https://www.udemy.com/course/ios11-app-development-bootcamp/learn/lecture/7719428?start=75#overview
Sau đó em/mình sẽ học https://www.raywenderlich.com/
và cuối cùng là khoá của standford online.
em/mình có một số thắc mắc khi học nên nhờ mọi người giúp đỡ.

  1. Hiện tại em đang tự học , sau 1 năm tự học em có thể trở thành senior k? anh chị nào có kiến thức trước xin cho em kinh nghiệm và chia sẽ dùm em con đường và các khoá học tài liệu để e có thể định hình và đi tiếp ạ.

  2. Hiện nay các thị trường ở singapore hay châu âu hay nhật bản việc tuyển dụng ios với senior ra sao ạ? Yêu cầu và lương ra sao ạ? từ một người mới học ios với tự học có thể sau 1 năm sẽ ứng tuyển được k ạ?

Mong có anh chị nào có kinh nghiệm trước đó xin được xin ý kiến ạ.

—English—
Hello everyone, now I am studing about iOS development on swift by online cousre on Udemy:
Link: https://www.udemy.com/course/ios11-app-development-bootcamp/learn/lecture/7719428?start=75#overview
then, I will learn https://www.raywenderlich.com/
after, I learn Stanford online.

I have some question, can You help me?
1. Now I am studying swift, after 1 years only I can become senior developer? Can I do it? Can you give me advice to done this plan. Can you give me course, book, or seminar to help me do my plan. Thanks a lot
2. Now, What condition of IOS Developer in Singapore, Europe or Japan. ? after one years only studying, Can I apply?
Can you help me to answer?
---日本語---
みなさん、おはようございます。
現在、私はオンラインコースでIOS開発を学んでいます。
Udemy: https://www.udemy.com/course/ios11-app-development-bootcamp/learn/lecture/7719428?start=75#overview
このコースが終わった後、 https://www.raywenderlich.com/
最後はstandford online.
私は学ぶ中に質問がございます。
1。上のコースを学べば、1年間だけ上級開発ができますか?出来なければ、他のコース、本、セミナーがございましたら、教えてくれませんか?
2。私はシンガポールやヨーロッパや日本にてIOS開発募集について条件はなんでしょうか?1年間だけ勉強すれば、募集しても大丈夫でしょうか?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Azure Face API を利用して爆速で顔認証アプリを作る(パート2)

こんにちは、renです。

今回はAzure Face APIを利用して簡単な顔認証アプリを作っていきます。

こちらの記事はパート2(アプリ編)です
パート1(認証編)はこちら

環境

macOS Catalina 10.15
Xcode 11.1
iOS13
Swift 5

アプリの流れ

初期表示画面

認証画面へ遷移

カメラ起動

顔検出

画像として取得

データ型に変換し、検出 Face - Detect APIに投げる

レスポンスの faceId を 取得し、識別 Face - identify APIに投げる

レスポンスの confidence をチェックし、 9割以上で認証を成功とみなす

ホーム画面へ遷移

準備

カメラを利用するアプリなので、 Info.plistPrivacy - Camera Usage Description を有効化してください。

下記ライブラリを利用するため Podfile を編集してください。

pod 'Moya/RxSwift'
pod 'RxSwift'
pod 'SVProgressHUD'

pod install を実行してください。

pod install

APIRequestの実装

初めに endpointSubscriptionKeyConst に定義しておきましょう。

public struct Const {
    static let endpoint = "Your Endpoint"
    static let subscriptionKey = "Your SubscriptionKey"

    static let baseURL = "https://\(endpoint)/face/v1.0/"
    static let personGroupId = "sample_person_group"
}

APIリクエストでは Moya を利用します。

Moya を利用するために、まず Target を作成します。

import Moya

enum Target {
    case detect(imageData: Data)
    case identify(faceId: String)
}

extension Target: TargetType {

    var baseURL: URL {
        return URL(string: Const.baseURL)!
    }

    var path: String {
        switch self {
        case .detect:
            return "detect"
        case .identify:
            return "identify"
        }
    }

    var method: Moya.Method {
        switch self {
        case .detect:
            return .post
        case .identify:
            return .post
        }
    }

    var sampleData: Data {
        return Data()
    }

    var task: Task {
        switch self {
        case .detect(let imageData):

            return .requestCompositeData(bodyData: imageData, urlParameters: ["recognitionModel" : "recognition_02"])

        case .identify(let faceId):
            let parameters: [String: Any] = [
                "personGroupId": Const.personGroupId,
                "faceIds": [faceId]
            ]

            return .requestParameters(parameters: parameters, encoding: JSONEncoding.default)

        }

    }

    var headers: [String: String]? {
        var header = [
            "Ocp-Apim-Subscription-Key" : Const.subscriptionKey
        ]

        switch self {
        case .detect:
            header["Content-Type"] = "application/octet-stream"
            return header
        case .identify:
            header["Content-Type"] = "application/json"
            return header
        }
    }

}

次は APIRequest クラスを作成します。

import RxSwift
import Moya
import SVProgressHUD

enum Result<ResponseModel: Codable, ErrorResponseModel: Codable> {
    case success(ResponseModel)
    case invalid(ErrorResponseModel)
    case failure(Error)
}

public final class APIRequest {

    private let provider = MoyaProvider<Target>()

    func request<ResponseModel: Codable, ErrorResponseModel: Codable>
        (target: Target, response: ResponseModel.Type, errorResponse: ErrorResponseModel.Type,
         completion: @escaping ((Result<ResponseModel, ErrorResponseModel>) -> Void )) {

        SVProgressHUD.setDefaultMaskType(.black)
        SVProgressHUD.show()
        provider.request(target) { result in
            SVProgressHUD.dismiss()
            switch result {
            case let .success(response):
                do {
                    let serializedResponse = try response.filterSuccessfulStatusCodes().map(ResponseModel.self)
                    dump(serializedResponse)

                    completion(.success(serializedResponse))

                } catch {
                    guard let errorResponse = self.serializeError(response: response, errorResponse: errorResponse) else { completion(.failure(error)); return }
                    dump(errorResponse)

                    completion(.invalid(errorResponse))

                }
            case let .failure(error):
                dump(error)
                completion(.failure(error))
            }
        }
    }

    private func serializeError<ErrorResponseModel: Codable>
        (response: Moya.Response, errorResponse: ErrorResponseModel.Type) -> ErrorResponseModel? {

        do {
            let errorResponse = try response.map(ErrorResponseModel.self)
            dump(errorResponse)
            return errorResponse
        } catch {
            return nil
        }
    }
}

次は、それぞれのAPIのレスポンスモデルを作成します。

struct FaceDetectResponse: Codable {
    let faceId: String
    let faceRectangle: FaceRectangle

    struct FaceRectangle: Codable {
        let top: Int
        let left: Int
        let width: Int
        let height: Int
    }
}

struct FaceIdentifyResponse: Codable {
    let faceId: String
    let candidates: [Candidates]

    struct Candidates: Codable {
        let personId: String
        let confidence: Double
    }

}

struct ErrorResponse: Codable {
    public let error: ErrorStatus

    struct ErrorStatus: Codable {
        let code: String
        let message: String
    }
}

これにてAPIリクエストの部分は完成です。

Viewの作成

次に View を作成します。下の画像を参考に作成してください。

スクリーンショット 2019-10-20 13.02.49.png

真ん中のView(FaceAuthView)のViewControllerを作成します。

import UIKit

class FaceAuthViewController: UIViewController {

    // カメラの映像が映るView
    @IBOutlet private weak var cameraView: UIView!
    // cameraViewに覆いかぶさっているView
    @IBOutlet private weak var overlayView: UIView!
    // overlayViewの中心の透明なView
    @IBOutlet private weak var centerView: UIView!
    // アラートを表示するためのLabel
    @IBOutlet private weak var alertLabel: UILabel!

    // 顔検出を行うクラス
    private var faceDetecer: FaceDetecer?
    // 顔の周りに表示する枠
    private let frameView = UIView()
    // 顔検出されたときの画像
    private var image = UIImage()

    // APIリクエストを行うクラス
    private let apiRequest = APIRequest()

    override func viewDidLoad() {
        setupFrameView()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setupFaceDetecer()
        dispOverlayView()
    }

    override func viewWillDisappear(_ animated: Bool) {
        if let faceDetecer = faceDetecer {
            faceDetecer.stopRunning()
        }
        faceDetecer = nil
    }

    private func setupFrameView() {
        frameView.layer.borderWidth = 3
        view.addSubview(frameView)
    }

    private func setupFaceDetecer() {
        faceDetecer = FaceDetecer(view: cameraView, completion: {faceRect, image in
            self.frameView.frame = faceRect
            self.image = image
            self.isInFrame(faceRect: faceRect)
        })
    }

    private func dispOverlayView() {
        overlayView.isHidden = false
    }

    private func isInFrame(faceRect: CGRect) {

        let xIsInFrame = centerView.frame.minX < faceRect.minX && faceRect.maxX < centerView.frame.maxX
        let yIsInFrame = centerView.frame.minY < faceRect.minY && faceRect.maxY < centerView.frame.maxY

        if xIsInFrame && yIsInFrame {
            stopRunning()
            faceDetect(image: image)
        }

    }

    private func faceDetect(image: UIImage) {

        guard let imageData = image.jpegData(compressionQuality: 1.0) else { return }

        apiRequest.request(target: .detect(imageData: imageData), response: [FaceDetectResponse].self, errorResponse: ErrorResponse.self) { respose in
            switch respose {
            case .success(let faceDetectResponse):

                guard let faceId = faceDetectResponse.first?.faceId else {
                    self.alertLabel.text = "顔認証に失敗しました"
                    self.startRunning()
                    return
                }

                self.faceIdentify(faceId: faceId)

            case .invalid(let errorResponse):
                print(errorResponse)
                self.startRunning()
            case .failure(let error):
                print(error)
                self.startRunning()
            }
        }

    }

    private func faceIdentify(faceId: String) {
        apiRequest.request(target: .identify(faceId: faceId), response: [FaceIdentifyResponse].self, errorResponse: ErrorResponse.self) { respose in
            switch respose {
            case .success(let faceIdentifyResponse):

                // 最初の顔で判定
                guard let candidate = faceIdentifyResponse.first?.candidates.first?.confidence else {
                    self.alertLabel.text = "顔が登録されていません"
                    self.startRunning()
                    return
                }

                let candidateInt = Int(candidate * 100)
                self.alertLabel.text = "信頼度は \(candidateInt)% です"

                if candidate > 0.9 {
                    self.login()
                } else {
                    self.startRunning()
                }

            case .invalid(let errorResponse):
                print(errorResponse)
                self.startRunning()
            case .failure(let error):
                print(error)
                self.startRunning()
            }
        }
    }

    private func startRunning() {
        guard let faceDetecer = faceDetecer else { return }
        faceDetecer.startRunning()
    }

    private func stopRunning() {
        guard let faceDetecer = faceDetecer else { return }
        faceDetecer.stopRunning()
    }

    private func login() {
        self.performSegue(withIdentifier: "gotoHome", sender: nil)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @IBAction private func tappedBackButton(_ sender: UIButton) {
        dismiss(animated: true, completion: nil)
    }

}

顔検出を行うクラス FaceDetecer を作成

import UIKit
import AVFoundation

final class FaceDetecer: NSObject {
    private let captureSession = AVCaptureSession()
    private var videoDataOutput = AVCaptureVideoDataOutput()
    private var view: UIView
    private var completion: (_ rect: CGRect, _ image: UIImage) -> Void

    required init(view: UIView, completion: @escaping (_ rect: CGRect, _ image: UIImage) -> Void) {
        self.view = view
        self.completion = completion
        super.init()
        self.initialize()
    }

    private func initialize() {
        addCaptureSessionInput()
        registerDelegate()
        setVideoDataOutput()
        addCaptureSessionOutput()
        addVideoPreviewLayer()
        setCameraOrientation()
        startRunning()
    }

    private func addCaptureSessionInput() {
        do {
            guard let frontVideoCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { return }
            let frontVideoCameraInput = try AVCaptureDeviceInput(device: frontVideoCamera) as AVCaptureDeviceInput
            captureSession.addInput(frontVideoCameraInput)
        } catch let error {
            print(error)
        }
    }

    private func setVideoDataOutput() {
        videoDataOutput.alwaysDiscardsLateVideoFrames = true

        guard let pixelFormatTypeKey = kCVPixelBufferPixelFormatTypeKey as AnyHashable as? String else { return }
        let pixelFormatTypeValue = Int(kCVPixelFormatType_32BGRA)

        videoDataOutput.videoSettings = [pixelFormatTypeKey : pixelFormatTypeValue]
    }

    private func setCameraOrientation() {
        for connection in videoDataOutput.connections where connection.isVideoOrientationSupported {
            connection.videoOrientation = .portrait
            connection.isVideoMirrored = true
        }
    }

    private func registerDelegate() {
        let queue = DispatchQueue(label: "queue", attributes: .concurrent)
        videoDataOutput.setSampleBufferDelegate(self, queue: queue)
    }

    private func addCaptureSessionOutput() {
        captureSession.addOutput(videoDataOutput)
    }

    private func addVideoPreviewLayer() {
        let videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        videoPreviewLayer.frame = view.bounds
        videoPreviewLayer.videoGravity = .resizeAspectFill

        view.layer.addSublayer(videoPreviewLayer)
    }

    func startRunning() {
        captureSession.startRunning()
    }

    func stopRunning() {
        captureSession.stopRunning()
    }

    private func convertToImage(from sampleBuffer: CMSampleBuffer) -> UIImage? {

        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return nil }

        CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))

        let baseAddress = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)
        let width = CVPixelBufferGetWidth(imageBuffer)
        let height = CVPixelBufferGetHeight(imageBuffer)

        let bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer)
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = (CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
        let context = CGContext(data: baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo)

        guard let imageRef = context?.makeImage() else { return nil }

        CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
        let resultImage = UIImage(cgImage: imageRef)

        return resultImage
    }
}

extension FaceDetecer: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        DispatchQueue.main.sync(execute: {

            guard let image = convertToImage(from: sampleBuffer), let ciimage = CIImage(image: image) else { return }
            guard let detector = CIDetector(ofType: CIDetectorTypeFace, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) else { return }
            guard let feature = detector.features(in: ciimage).first else { return }

            sendFaceRect(feature: feature, image: image)

        })
    }

    private func sendFaceRect(feature: CIFeature, image: UIImage) {
        var faceRect = feature.bounds

        let widthPer = view.bounds.width / image.size.width
        let heightPer = view.bounds.height / image.size.height

        // 原点を揃える
        faceRect.origin.y = image.size.height - faceRect.origin.y - faceRect.size.height

        // 倍率変換
        faceRect.origin.x *= widthPer
        faceRect.origin.y *= heightPer
        faceRect.size.width *= widthPer
        faceRect.size.height *= heightPer

        completion(faceRect, image)
    }
}

これで実装は終了です。

AzureFaceAPIは無料プランだと1分間に20回までしかリクエストを送れないのでご注意ください。

あとがき

今回はリアルタイム顔検出とAzureFaceAPIの連携で顔認証アプリを作成しました。
初めてカメラの機能を実装したのですが、思ったよりも簡単にできて驚きました。
これも普段からQiitaを書いてくださる皆様のおかげだと思います。ありがとうございます。

サンプルアプリはGitHubに公開しています。
https://github.com/renchild8/FaceAuthSample

この記事は下記の記事を参考にしています。
[コピペで使える]swift3/swift4/swift5でリアルタイム顔認識をする方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Azure Face API を利用して爆速で顔認証アプリを作る(パート1)

こんにちは、renです。

今回はAzure Face APIを利用して簡単な顔認証アプリを作っていきます。

こちらの記事はパート1(認証編)です
パート2(アプリ編)はこちら

準備

  1. Azureアカウントの取得
  2. リソースグループの作成
  3. リソースの作成
  4. endpoint , Subscription key の取得

※ 詳しい情報は公式をご参照ください

認証の流れ

検出 Face - Detect APIに投げる

レスポンスの faceId を 取得し、識別 Face - Identify APIに投げる

認証の準備

認証を行うためには、認証の対象を作る必要があります。
Face - Identify では PersonGroup (または LargePersonGroup )というグループに対して認証を行います。
PersonGroup とはその名の通りで人のグループです。
まず PersonGroup を作成し、そこに人の情報 PersonGroup Person を作成します。
その後 PersonGroup Person に対して、顔の情報を追加します。
最後に PersonGroup をトレーニングすることで認証の対象が作成されます。

フロー

PersonGroup - CreatePersonGroup を作成

PersonGroup Person - CreatePersonGroupPerson を作成

PersonGroup Person - Add FacePersonFace を追加(20枚くらい)

PersonGroup - TrainPersonGroup をトレーニングする

認証対象の作成

アプリ側で認証対象を作成するのは面倒なのでcURLで作成していきます。
${ENDPOINT}${SUBSCRIPTION_KEY}は取得したものに置き換えてください。

予め export しておくとコピペで動かせます。

export ENDPOINT=XXXXX_YOUR_ENDPOINT_XXXXX
export SUBSCRIPTION_KEY=XXXXX_YOUR_SUBSCRIPTION_KEY_XXXXX

1.PersonGroup - Create PersonGroup を作成

sample_person_group という PersonGroup を作成します。

Request URL

https://{endpoint}/face/v1.0/persongroups/{personGroupId}

Request parameters

personGroupId (必須) : PersonGroup のID

Request body

name (必須) : PersonGroupの表示名
userData : ユーザー提供のデータ
recognitionModel : 認識モデル

recognitionModelについて

recognition_01
Face-Detectのデフォルトの認識モデル。
2019年3月より前に作成されたすべてのfaceIdは、この認識モデルに結合されます。

recognition_02
2019年3月にリリースされた認識モデル。
「recognition_01」と比較して全体的な精度が向上しているため、「recognition_02」をお勧めします。

今回は recognition_02 を使用します。

サンプル

curl -X PUT "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Type: application/json" -d "{\"name\":\"SamplePersonGroup\",\"userData\":\"Sample\",\"recognitionModel\":\"recognition_02\"}"
成功時レスポンスなし

ちゃんと作成できているか確認してみましょう。
PersonGroup - Get を利用するとPersonGroupの情報を取得できます。

curl "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group?returnRecognitionModel=true" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}"
{
  "recognitionModel": "recognition_02",
  "personGroupId": "sample_person_group",
  "name": "SamplePersonGroup",
  "userData": "Sample"
}

2.PersonGroup Person - Create PersonGroupPerson を作成

作成した sample_person_grouprenchild という Person を作成します。

Request URL

https://{endpoint}/face/v1.0/persongroups/{personGroupId}/persons

Request parameters

personGroupId (必須) : PersonGroup のID

Request body

name (必須) : Personの表示名
userData : ユーザー提供のデータ

サンプル

curl -X POST "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/persons" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Type: application/json" -d "{\"name\":\"renchild\", \"userData\":\"Sample Person\"}"
{
  "personId": "53d462fe-c56b-40f2-b435-5003e5eb0297"
}

ちゃんと作成できているか確認してみましょう。
PersonGroup Person - Get を利用すると Person の情報を取得できます。

curl "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/persons/53d462fe-c56b-40f2-b435-5003e5eb0297" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}"
{
  "personId": "53d462fe-c56b-40f2-b435-5003e5eb0297",
  "persistedFaceIds": [],
  "name": "renchild",
  "userData": "Sample Person"
}

補足

PersonGroup Person - List を利用するとPersonGroup内のPersonの情報を一覧で取得できます。

curl "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/persons" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}"
[
  {
    "personId": "53d462fe-c56b-40f2-b435-5003e5eb0297",
    "persistedFaceIds": [],
    "name": "renchild",
    "userData": "Sample Person"
  }
]

3.PersonGroup Person - Add FacePersonFace を追加

作成した renchildFace を追加します(20枚くらいあるといいらしい)

Request URL

https://{endpoint}/face/v1.0/persongroups/{personGroupId}/persons/{personId}/persistedFaces[?userData][&targetFace][&detectionModel]

Request parameters

personGroupId (必須) : PersonGroup のID
personId (必須) : Person のID
userData : ユーザー提供のデータ
targetFace : 追加するターゲットの顔を指定するための四角形
detectionModel : 顔検出モデル

detectionModelについて

detection_01
すべての顔検出操作に対する既定の選択です。
小さい顔、横顔、不鮮明な顔には最適化されていません。
検出呼び出しで顔の属性 (頭部姿勢、年齢、感情など) が指定されている場合は、それらを返します。
検出呼び出しで顔のランドマークが指定されている場合は、それらを返します。

detection_02
2019 年 5 月にリリースされ、すべての顔検出操作でオプションとして利用可能です。
小さい顔、横顔、不鮮明な顔での精度が向上しています。
顔の属性を返しません。
顔のランドマークを返しません。

今回は detection_02 を使用します。

Request body

画像のURLまたは画像のバイナリデータ

サンプル

curl -X POST "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/persons/53d462fe-c56b-40f2-b435-5003e5eb0297/persistedFaces?detectionModel=detection_02" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Type: application/octet-stream" --data-binary "@image.jpg" 

4.PersonGroup - TrainPersonGroup をトレーニングする

トレーニングの前に PersonGroup Person - List を利用して PersonGroup 内の Person の情報を確認しましょう。

curl "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/persons" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}"
[
  {
    "personId": "53d462fe-c56b-40f2-b435-5003e5eb0297",
    "persistedFaceIds": [
      "00f850fd-1f68-46ed-a7cc-5c0db171f4cf",
      "3f665c92-a141-4544-818c-36a63b39ac5b",
      "5ce289ec-362e-4be5-8472-18b71674137c",
      "733ceabc-4ef2-4d10-ba57-cfe2ee8ad7b2",
      "751850ac-4d54-4011-8f3c-db2c2b48dbb2",
                    
      "78cdf5f0-e3ea-4cd8-ba4b-eb156a14faf7",
      "7f2a834b-5e9a-4238-a099-8e2cc3c1021e",
      "85d4dc2c-17d0-4f97-9c9d-460b4512dd56",
      "990778dc-be2a-4a1f-9281-21fcb6aa6b7c",
      "a511e64b-8f1b-478e-b83a-753e516a93d9",
      "a735d0e1-26f1-4e50-aafa-9a48a3ff07e0",
      "b40f1ed1-91ea-4e8d-84f7-f113dccc1bbb"
    ],
    "name": "renchild",
    "userData": "Sample Person"
  }
]

こんな感じで出ればOKです。

PersonGroup を トレーニング

Request URL

https://{endpoint}/face/v1.0/persongroups/{personGroupId}/train

Request parameters

personGroupId (必須) : PersonGroup のID

Request body

なし

サンプル

curl -X POST "https://${ENDPOINT}/face/v1.0/persongroups/sample_person_group/train" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Length: 0"
成功時レスポンスなし

これにて認証の準備は完了です。

認証

認証を行う際に faceId が必要です。
faceIdFace - Detect にて生成される顔の特徴の識別子です。
保存された faceId は24時間後に期限切れになり、削除されます。

※ 先程 PersonGroup に登録した persistedFaceIds は24時間の制限はなく、永続化されます。

Face - Detect で顔を検出する

Request URL

https://{endpoint}/face/v1.0/detect[?returnFaceId][&returnFaceLandmarks][&returnFaceAttributes][&recognitionModel][&returnRecognitionModel][&detectionModel]

Request parameters

returnFaceId : 検出された顔の faceId を返すかどうか。デフォルト値は true
returnFaceLandmarks : 検出された顔の顔のランドマークを返すかどうか。デフォルト値は false
returnFaceAttributes : 年齢、性別、眼鏡、感情 などを分析する属性
recognitionModel : 認識モデル
returnRecognitionModel : recognitionModel を返すかどうか。デフォルト値は false です。
detectionModel : 顔検出モデル

PersonGroup の作成時に recognitionModelrecognition_02 に指定しているので、
今回もrecognitionModelrecognition_02 に指定します。

recognitionModel が異なる場合 Face - Identify にて識別を行うことができなくなります。

Request body

画像のURLまたは画像のバイナリデータ

サンプル

curl -X POST "https://${ENDPOINT}/face/v1.0/detect?recognitionModel=recognition_02" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Type: application/octet-stream" --data-binary "@image.jpg" | jq .
[
  {
    "faceId": "5e2d9794-3fba-4324-88bd-6ec2944583e0",
    "faceRectangle": {
      "top": 679,
      "left": 667,
      "width": 969,
      "height": 969
    }
  }
]

Face - Identify で顔を識別する

Face - Detect で取得した faceId で顔の識別を行う

Request URL

https://{endpoint}/face/v1.0/identify

Request body

faceIds (必須) : Face - Detect によって作成された faceId の配列
personGroupId (※必須) : PersonGroup - Create によって作成された personGroupId
largePersonGroupId (※必須) : LargePersonGroup - Create によって作成された largePersonGroupId
maxNumOfCandidatesReturned : 候補者の最大数の範囲。 1〜100の範囲(デフォルトは10)
ConfidenceThreshold : 0~1 の範囲のカスタマイズされた信頼度しきい値

※ パラメーター personGroupIdlargePersonGroupIdを同時に指定しないでください。

サンプル

curl -X POST "https://${ENDPOINT}/face/v1.0/identify" -H "Ocp-Apim-Subscription-Key: ${SUBSCRIPTION_KEY}" -H "Content-Type: application/json" -d "{\"personGroupId\": \"sample_person_group\", \"faceIds\": [\"5e2d9794-3fba-4324-88bd-6ec2944583e0\"]}"
[
  {
    "faceId": "5e2d9794-3fba-4324-88bd-6ec2944583e0",
    "candidates": [
      {
        "personId": "53d462fe-c56b-40f2-b435-5003e5eb0297",
        "confidence": 0.93879
      }
    ]
  }
]

レスポンスの confidence が信頼度です。
こちらの値を利用して判定を行うことになります。

あとがき

準備が若干面倒ですが、数十枚の画像データで結構いい精度で判定してくれるのでみなさんも試してみてください。
次回はアプリとの連携を行います。

パート2(アプリ編)はこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSでリアルタイム顔検出を行う

こんにちはrenです。

今回はリアルタイム顔検出のアプリを作っていきます。
一週間程度でぱぱっと作ったものなので、間違いなどあればご指摘等いただけると嬉しいです。

環境

macOS Catalina 10.15
Xcode 11.1
iOS13
Swift 5

流れ

「Login」ボタンをタップ

フロントカメラを起動する

出力された映像を切り出し、画像に変換する

画像から顔を検出する

検出された顔の座標にフレームを表示させる

準備

今回はカメラを利用するのでInfo.plistPrivacy - Camera Usage Descriptionを追加してください

実装

// 初期表示View
import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func tappedLogin(_ sender: Any) {
        self.performSegue(withIdentifier: "gotoFaceDetect", sender: nil)
    }

}

// 顔検出をするView
import UIKit

class FaceDetectViewController: UIViewController {

    @IBOutlet weak var cameraView: UIView!

    // 顔検出をするためのクラス
    private var faceDetecter: FaceDetecter?
    // 検出された顔のフレームを表示するためのView
    private let frameView = UIView()
    // 切り出された画像
    private var image = UIImage()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setup()
    }

    override func viewWillDisappear(_ animated: Bool) {
        if let faceDetecter = faceDetecter {
            faceDetecter.stopRunning()
        }
        faceDetecter = nil
    }

    private func setup() {
        frameView.layer.borderWidth = 3
        view.addSubview(frameView)
        faceDetecter = FaceDetecter(view: cameraView, completion: {faceRect, image in
            self.frameView.frame = faceRect
            self.image = image
        })
    }

    private func stopRunning() {
        guard let faceDetecter = faceDetecter else { return }
        faceDetecter.stopRunning()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @IBAction func tappedBackButton(_ sender: UIButton) {
        dismiss(animated: true, completion: nil)
    }

}

// 顔検出をするクラス
import UIKit
import AVFoundation

final class FaceDetecter: NSObject {
    private let captureSession = AVCaptureSession()
    private var videoDataOutput = AVCaptureVideoDataOutput()
    private var view: UIView
    private var completion: (_ rect: CGRect, _ image: UIImage) -> Void

    required init(view: UIView, completion: @escaping (_ rect: CGRect, _ image: UIImage) -> Void) {
        self.view = view
        self.completion = completion
        super.init()
        self.initialize()
    }

    private func initialize() {
        addCaptureSessionInput()
        registerDelegate()
        setVideoDataOutput()
        addCaptureSessionOutput()
        addVideoPreviewLayer()
        setCameraOrientation()
        startRunning()
    }

    private func addCaptureSessionInput() {
        do {
            guard let frontVideoCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { return }
            let frontVideoCameraInput = try AVCaptureDeviceInput(device: frontVideoCamera) as AVCaptureDeviceInput
            captureSession.addInput(frontVideoCameraInput)
        } catch let error {
            print(error)
        }
    }

    private func setVideoDataOutput() {
        videoDataOutput.alwaysDiscardsLateVideoFrames = true

        guard let pixelFormatTypeKey = kCVPixelBufferPixelFormatTypeKey as AnyHashable as? String else { return }
        let pixelFormatTypeValue = Int(kCVPixelFormatType_32BGRA)

        videoDataOutput.videoSettings = [pixelFormatTypeKey : pixelFormatTypeValue]
    }

    private func setCameraOrientation() {
        for connection in videoDataOutput.connections where connection.isVideoOrientationSupported {
            connection.videoOrientation = .portrait
            connection.isVideoMirrored = true
        }
    }

    private func registerDelegate() {
        let queue = DispatchQueue(label: "queue", attributes: .concurrent)
        videoDataOutput.setSampleBufferDelegate(self, queue: queue)
    }

    private func addCaptureSessionOutput() {
        captureSession.addOutput(videoDataOutput)
    }

    private func addVideoPreviewLayer() {
        let videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        videoPreviewLayer.frame = view.bounds
        videoPreviewLayer.videoGravity = .resizeAspectFill

        view.layer.addSublayer(videoPreviewLayer)
    }

    func startRunning() {
        captureSession.startRunning()
    }

    func stopRunning() {
        captureSession.stopRunning()
    }

    private func convertToImage(from sampleBuffer: CMSampleBuffer) -> UIImage? {

        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return nil }

        CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))

        let baseAddress = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)
        let width = CVPixelBufferGetWidth(imageBuffer)
        let height = CVPixelBufferGetHeight(imageBuffer)

        let bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer)
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = (CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
        let context = CGContext(data: baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo)

        guard let imageRef = context?.makeImage() else { return nil }

        CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: 0))
        let resultImage = UIImage(cgImage: imageRef)

        return resultImage
    }
}

extension FaceDetecter: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        DispatchQueue.main.sync(execute: {

            guard let image = convertToImage(from: sampleBuffer), let ciimage = CIImage(image: image) else { return }
            guard let detector = CIDetector(ofType: CIDetectorTypeFace, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) else { return }
            guard let feature = detector.features(in: ciimage).first else { return }

            sendFaceRect(feature: feature, image: image)

        })
    }

    private func sendFaceRect(feature: CIFeature, image: UIImage) {
        var faceRect = feature.bounds

        let widthPer = view.bounds.width / image.size.width
        let heightPer = view.bounds.height / image.size.height

        // 原点を揃える
        faceRect.origin.y = image.size.height - faceRect.origin.y - faceRect.size.height

        // 倍率変換
        faceRect.origin.x *= widthPer
        faceRect.origin.y *= heightPer
        faceRect.size.width *= widthPer
        faceRect.size.height *= heightPer

        completion(faceRect, image)
    }
}

この記事は下記の記事を参考にしています。
[コピペで使える]swift3/swift4/swift5でリアルタイム顔認識をする方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[iOS]アプリ申請完全自動化

アプリの申請を完全に自動化する

ソースコードをpushしたら、あとは何も考えなくてもストアにリリースされる。
そんな環境を作ってみたのでメモ
まだちゃんと動かなさそう(fastlane_sessionが1ヶ月でexpireする)

全体の流れ

IMG_88DD0533F907-1.JPG

↑こんな感じ

Bitriseの部分は別になんでも良いのだけど、無料枠があったのでBitriseにしている。
automate.io は、いわゆるIFTTTのようなサービス。
似たようなサービスでメルカリの採用しているZapierと言うのがあるが、カスタムWebhookがプレミアム専用なのでautomate.ioを使うことにした。
正直Zapierの方がsampleみながらzap作れるしgmailの取得速いし、正規表現ちゃんと使えるのでZapierの方がおすすめです。

GithubにpushしてBitriseからappstore connectへアップロードする

ここは簡単。
bitrise.ioにサインアップしたら、チュートリアルの通りに進めばリポジトリとbitriseの連携が完了する。
あとは「Deploy to iTunes Connect - Application Loader」というstepを追加して、適宜情報を書き込めばアップロードされるようになる。
注意点としては、build numberをincrementすることを忘れずに。同じビルド番号のipaはアップロードに失敗します。
build numberの設定は「Set Xcode Project Build Number」というStepを使えば簡単に設定できます。
そして、書いていて思ったけどこの時点でappstore connectに新規のバージョンが無い可能性があるのでここはfastlaneのdeliver使った方がいいですね。

AppStore ConnectのCompleted Processを待つ

ipaをアップロードすると、appleは自動的にipaの健康状態をチェックしてくれます。(悪いことしているとこの時点で弾かれる)
ここは大体数分から数時間と幅があるのでひたすら待ちます。
待っていると、AppleからApp Store Connect: Version 1.0 (1) for AppName has completed processing.というタイトルのメールが届きます。

Completed Processingのメールが届いたらbitriseのworkflowを発火させる

automate.ioを使ってgmailを監視して、メールが届いたらbitirseのapiを叩きます。
メールにはbuild numberとversionも記載されているので、これらをパースしてbitriseの環境変数に乗せておきます。

スクリーンショット 2019-10-20 6.22.26.png

automate.ioのbotの中身はこんな感じ。
正規表現機能がガバガバなので、タイトルをスペースで区切って取り出しています。

bitriseからサブミットする

automate.ioのwebhookから叩かれたworkflowでfastlaneのdeliverを呼びます。
この辺は特に難しいところはないです。
サブミットするビルドのbundle numberなどは環境変数に入っているのでそれを使います。
fastlaneのascへの接続はFASTLANE_SESSIONを利用していますが、2FA切ったアカウントで認証しても良いと思います。

完成

あとはbitriseのtriggerなどを調整しながら、使いやすいようにしていきましょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[iOS]アプリ申請完全自動化したい

アプリの申請を完全に自動化する

ソースコードをpushしたら、あとは何も考えなくてもストアにリリースされる。
そんな環境を作ってみたのでメモ
まだちゃんと動かなさそう

全体の流れ

IMG_88DD0533F907-1.JPG

↑こんな感じ

Bitriseの部分は別になんでも良いのだけど、無料枠があったのでBitriseにしている。
automate.io は、いわゆるIFTTTのようなサービス。
似たようなサービスでメルカリの採用しているZapierと言うのがあるが、カスタムWebhookがプレミアム専用なのでautomate.ioを使うことにした。
正直Zapierの方がsampleみながらzap作れるしgmailの取得速いし、正規表現ちゃんと使えるのでZapierの方がおすすめです。

GithubにpushしてBitriseからappstore connectへアップロードする

ここは簡単。
bitrise.ioにサインアップしたら、チュートリアルの通りに進めばリポジトリとbitriseの連携が完了する。
あとは「Deploy to iTunes Connect - Application Loader」というstepを追加して、適宜情報を書き込めばアップロードされるようになる。
注意点としては、build numberをincrementすることを忘れずに。同じビルド番号のipaはアップロードに失敗します。
build numberの設定は「Set Xcode Project Build Number」というStepを使えば簡単に設定できます。
そして、書いていて思ったけどこの時点でappstore connectに新規のバージョンが無い可能性があるのでここはfastlaneのdeliver使った方がいいですね。

AppStore ConnectのCompleted Processを待つ

ipaをアップロードすると、appleは自動的にipaの健康状態をチェックしてくれます。(悪いことしているとこの時点で弾かれる)
ここは大体数分から数時間と幅があるのでひたすら待ちます。
待っていると、AppleからApp Store Connect: Version 1.0 (1) for AppName has completed processing.というタイトルのメールが届きます。

Completed Processingのメールが届いたらbitriseのworkflowを発火させる

automate.ioを使ってgmailを監視して、メールが届いたらbitirseのapiを叩きます。
メールにはbuild numberとversionも記載されているので、これらをパースしてbitriseの環境変数に乗せておきます。

スクリーンショット 2019-10-20 6.22.26.png

automate.ioのbotの中身はこんな感じ。
正規表現機能がガバガバなので、タイトルをスペースで区切って取り出しています。

bitriseからサブミットする

automate.ioのwebhookから叩かれたworkflowでfastlaneのdeliverを呼びます。
この辺は特に難しいところはないです。
サブミットするビルドのbundle numberなどは環境変数に入っているのでそれを使います。
fastlaneのascへの接続はFASTLANE_SESSIONを利用していますが、2FA切ったアカウントで認証しても良いと思います。

完成

あとはbitriseのtriggerなどを調整しながら、使いやすいようにしていきましょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む