考古学者の心得と、遺跡調査に使える道具

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の5日目の記事です。

ご無沙汰しています、ハードウェア開発チームに来たはいいものの、実は低レイヤ開発はあまりしていない daikw - Qiita です。最近は主に考古学者をしていました。

Akerunサービスにおいて、ファームウェアの開発は大きなボリュームを占めるものの一つ、当然重要です。 しかし、作ったそばから陳腐化・レガシー化・遺跡化していくものばかり。

「ドキュメント? そんなものはない

人手があったとしても、これは避けられないのかもしれない。 *1

従って、考古学の力が試されるなぁ、と感じる日々を過ごしています。 今日は記事を書きながら、遺跡調査の道具箱を整理しよう!

言葉の定義

さて、まず智慧を司るトト神の不興を買わないように、未定義語 *2 を減らす努力をします。 誠意を持った祈りは届くのです 🙏 📿

  • 陳腐化: 一般的には、古くなり、当初の価値を減じた状態のこと。*3 本記事で特定のソースコードやシステムに対して使った場合、誰にも読まれなくなり、誰も把握していない状態を指す。
  • 遺跡・ダンジョン: 本記事では、陳腐化した状態の特定のシステム(ネットワーク・サーバ等の集合)を指す。
  • 考古学・遺跡調査: 本記事では、遺跡に対しての活動全般を指す。調査をした上で、何か変更をすることが多い。 実は Software archaeology - Wikipedia という分野があり、伊達や酔狂とも言い切れない。
  • 道具箱: 本記事では、主にコマンドラインツールの集合を指す。ヘッドレスサーバを扱うことが多いため、デスクトップアプリケーションはたまにしか出てこない。
  • トト神: 知恵の神、書記の守護者、時の管理人、楽器の開発者、創造神、または森羅万象を把握しているスーパーエンジニアのこと。 / トート - Wikipedia より。

考え方

考古学者の心得リスト

まず、考古学マインドセットを内面化するための、心得リストを用意します。

今にも崩れそうな遺跡に苦しむ子羊たちを導かんと、各地のトト神による ヒエログリフ解読方法託宣黒魔術 といったコンテンツがインターネット上に存在します。

その中で、1. 特に抽象度が高い または 2. レビュー論文的性質がある ものを心得リストとしました。

心得リストからの教訓

例えば ノーヒントサーバ調査 (Linux編) - Qiita # 調査の心構え より抜粋すると、

  • サーバの今がわかる
    • 「現在サーバがどうなっているか」に答えられるようになる
    • 現状調査の方法 理解が必要
      • サーバスペック / サーバ構成 / リソース状況 / ...
  • サーバの過去がわかる
    • 「過去サーバがどうなっていたか」に答えられるようになる
    • 主に ログの読み方 理解が必要
  • サーバの未来がわかる
    • 「今後サーバがどうなりそうか」に答えられるようになる
    • もちろんここは 推論 が入る

  • 事実と推論を分けて整理する
    • ノーヒントであるがゆえに、当然ファーストステップは推論になるのですが、推論に推論を重ねていくと意味がわからなくなるので、推論のあとは事実確認するようなことが望ましいです
    • そういう意味では一人でガリガリ調べ続けるよりは、別の人も巻き込んでワイワイ(?)やるのがいいですね
  • 調査事故を起こさない
    • たまに障害調査で 二次障害 を引き起こすケースがあります
      • 安易なroot作業や、調査作業によるリソース占有など
    • 「今自分がなにをやろうとしているか?」についてはいつも 気をつけましょう
    • やらかしてしまった場合に備える意味でも ターミナルログ は必ず取りましょう
      • 多分どんなターミナルでもログは取れますが、もし取れないものを使っているとしたらそれは窓から投げ捨てましょう(ポイー

他の心得リストからも、似たような教訓が得られます。概ね、以下の2つに大別されます。

  • 歴史に学べ
  • 記録せよ

歴史に学ぶ

手がかりが本当に全くない、という状況は少なく、探索的調査によって明らかにできます(強い言葉を(ry 。

そもそも遺跡が存在していること自体がヒントになり、歴史は社内の情報源のクロールによって明らかに(きっと)できます。 歴史を知った段階で、ダンジョン化の要因を把握し取り除くことができればより好ましく、価値のある活動に。

資源配分・ITへの無理解 などの構造的問題や、単なるサボり・技術力不足といった小さい問題のこともあります。

記録する

これまで記録がなかったから、ダンジョン化しているわけであって、悲劇を繰り返す必要はありません。 憎しみの連鎖は自分の代で断つ、という覚悟が重要です。

記録し、公開し、騒いで、多くの目に触れさせることで、徐々に良くしていきましょう。 ただし、政治的活動や緻密な戦略が必要なことも、、、

qiita.com zenn.dev

道具

dic.nicovideo.jp

「歴史に学」び、「記録」するために、十分リッチな道具を用意します。各記事からざっくり抜粋し、まとめました。

記載のコマンドは全て、manページをざっくり読み、オプションを含めた概要を掴んでおくことが推奨されます。 といってもdistro間の細かい違いまで抑えるのはやはり困難です。都度、必要な道具を探し、見つけ、学ぶことが肝要そう。

一番いいのを頼む

# ターミナル操作ログを残す。自動で残るようにしておくと便利。
script $file_name

# 外部から、又は外部環境ごと調べる
dig
nslookup
ping
traceroute
nmap
telnet
arp
nc
curl
wget

# 証跡やログ
history
last
journalctl
ls /var/log
less /var/log/messages

# リソース
uptime

cat /proc/cpuinfo
cat /proc/meminfo

dmesg | tail

free -mh
df -h
vmstat 1 5

[h]top

## `sysstat` が必要
mpstat -P ALL 1
pidstat 1
iostat -xz 1
sar -n DEV 1
sar -n TCP,ETCP 1

# ペリフェラル
ls -l /dev

# 固有情報
uname -a
lsb_release
ls /etc/*release
cat /etc/os-release

# プロセス
ps -A o user,command --no-header --sort command | grep -v -e '\s\['
ps aux[f]
pgrep $name
strace -p [-f] $pid [-e trace=$type]

# Cron / Daemon
ls /var/spool/cron/
ls /etc/crontab/
ls /etc/cron.d/
ls /etc/cron.hourly/
ls /etc/cron.daily/
ls /etc/cron.weekly/
ls /etc/cron.monthly/

systemctl list-units --type=service
systemctl list-unit-files --type=service

# ネットワーク
ip a
ip a s $nic
netstat -lntup
ss

# パッケージリスト
dpkg -l
dpkg-query -l
zgrep install /var/log/dpkg.log*

apt list --installed
zgrep Commandline /var/log/apt/history.log.*

comm -23 <(apt-mark showmanual | sort -u) <(gzip -dc /var/log/installer/initial-status.gz | sed -n 's/^Package: //p' | sort -u)
### ref: https://askubuntu.com/questions/2389/how-to-list-manually-installed-packages

rpm -qa --last
yum list installed

snap list
flatpak list

# 特徴的なファイルを見つける・中身を探る
du -m / --max-depth=3 --exclude="/proc*" | sort -k1 -n -r
find /etc -maxdepth 3 -type f -printf "%p %TY-%Tm-%Td\n" | sort -k2 -r
find /home/pi/ -type f -exec du -sm {} \; | sort -nrk1 | head
find /var/log/ -type f -exec du -sm {} \; | sort -nrk1 | head

file $file_name
stat $file_name

参考リンク


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

*1: そもそも全てが IaC化 / 仕組み化されていれば、こんなことを考える必要はない...。 弱者の論理ではある。

*2: Undefined (mathematics) - Wikipedia

*3: 陳腐化とは - コトバンク

専用アプリはもう要らない?LINE BotからAkerunを操作する(その3)

この記事は Akerun Advent Calendar 2021 - Qiita の2日目の記事です。

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

2019年と2020のAdvent Calendarでは、「LINE BotからAkerunを操作する(その1)と(その2)」を書かせていただきました。 この2回でご紹介したAkerun APIの基本機能(施錠解錠)とLINE Bot(基本機能、LINEログイン、グループ機能)を連携すれば、十分使えると思いますが、今年の3回目では、Akerun APIから取得した事業所/Akerun/合鍵/ユーザー情報をLINE Botの少しリッチな表示機能のカルーセル表示についてご紹介します。

akerun.hateblo.jp

akerun.hateblo.jp

事前準備

LINE Botの設定とAkerun APIアクセストークンの生成

その1を参考にLINE Botを作成し、Botと友達になります。

アクセストークンについては、弊社のAkerun Developersをご参照ください。

developers.akerun.com

Akerun API

以下のAkerun APIを使って、事業所一覧、事業所詳細、Akerun一覧、Akerun詳細、合鍵一覧、合鍵詳細、ユーザー一覧、ユーザー詳細を取得します。

Akerun APIの詳細についてAkerun Developersをご参照ください。

事業所一覧
GET https://api.akerun.com/v3/organizations

事業所詳細
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}

Akerun一覧
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/akeruns

Akerun詳細
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/akeruns/{AKERUN_ID}

合鍵一覧
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/keys

合鍵詳細
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/keys/{KEY_ID}

ユーザー一覧
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/users

ユーザー詳細
GET https://api.akerun.com/v3/organizations/{ORGANIZATION_ID}/users/{USER_ID}

pythonのコードは下記になります。

def akerun_bot_get_organizations(akerun_token):
  ak_url = 'https://api.akerun.com/v3/organizations'
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["organizations"] if resp_data is not None else []

def akerun_bot_get_organization_info(akerun_token, org_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}'.format(org_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["organization"] if resp_data is not None else None

def akerun_bot_get_akeruns(akerun_token, org_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/akeruns'.format(org_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["akeruns"] if resp_data is not None else []

def akerun_bot_get_akerun_info(akerun_token, org_id, ak_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/akeruns/{}'.format(org_id, ak_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["akerun"] if resp_data is not None else None

def akerun_bot_get_keys(akerun_token, org_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/keys'.format(org_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["keys"] if resp_data is not None else []

def akerun_bot_get_key_info(akerun_token, org_id, key_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/keys/{}'.format(org_id, key_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["key"] if resp_data is not None else None

def akerun_bot_get_users(akerun_token, org_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/users'.format(org_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["users"] if resp_data is not None else []

def akerun_bot_get_user_info(akerun_token, org_id, user_id):
  ak_url = 'https://api.akerun.com/v3/organizations/{}/users/{}'.format(org_id, user_id)
  ak_headers = {'Authorization': 'Bearer ' + akerun_token}
  resp_data = akerun_bot_api_get(ak_url, ak_headers)
  return resp_data["user"] if resp_data is not None else None

def akerun_bot_api_get(url, headers):
  obj_session = Session()
  obj_request = Request("GET",
              url,
              headers=headers
              )
  obj_prepped = obj_session.prepare_request(obj_request)
  obj_response = obj_session.send(obj_prepped,
                  verify=True,
                  timeout=60
                  )
  
  print('status_code:' + str(obj_response.status_code))
  print('obj_response.text:' + obj_response.text)
  if obj_response.status_code == 200:
    response_dict = json.loads(obj_response.text)
    print(response_dict)
    return response_dict

LINE Bot(Messaging API)のテンプレートメッセージ

今回はテンプレートメッセージのカルーセルと画像カルーセルを使って、事業所とAkerunを少しリッチに表示します。

developers.line.biz

事業所一覧(カルーセルテンプレート)

Akerunのユーザーは複数の事業所に所属できるので、事業所一覧を横スクロール可能なカルーセルテンプレートで表示します。

ルーセルテンプレートは複数のカラムを表示するテンプレートです。カラムは横にスクロールして順番に表示できます。

複数のActionも設定可能なので、事業所のAkerun一覧、合鍵一覧、ユーザー一覧を取得するActionも追加可能です。

事業所一覧取得メニューをTapしたら、Akerun APIから事業所一覧と詳細を取得し、カルーセルテンプレートで返すようにしています。

※今回は適当に「organizations」文字列で代替していますが、その1を参考にリッチメニューにメニューを追加すれば良いと思います。

# TextMessageとして「organizations」が通知される
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
  reply_msgs = []
  if event.message.type == 'text':
      cmd = event.message.text
      # 環境変数から取得したTokenを使用
      akerun_token = akerun_temp_token
      if cmd == 'organizations':
        organizations = akerun_bot_get_organizations(akerun_token)
        org_action_columns = []
        for org in organizations:
          org_id = org['id']
          if org_info is not None:
            org_name = org_info['name']
            org_id = org_info['id']
            org_action_columns.append(
              CarouselColumn(
                title = org_name,
                text = u'事業所の詳細情報を取得',
                default_action = PostbackTemplateAction(
                  label=u'Akerun一覧を取得',
                  data=json.dumps({'org_id': org_id})
                ),
                actions = [
                  PostbackTemplateAction(
                    label=u'Akerun一覧を取得',
                    data=json.dumps({'cmd': 'get_akeruns', 'org_id': org_id})
                  ),
                  PostbackTemplateAction(
                    label=u'鍵一覧を取得',
                    data=json.dumps({'cmd': 'get_keys', 'org_id': org_id})
                  ),
                  PostbackTemplateAction(
                    label=u'ユーザー一覧を取得',
                    data=json.dumps({'cmd': 'get_users', 'org_id': org_id})
                  ),
                ]
              )
            )

            # 最大10個
            if len(akerun_action_columns) >= 10:
              break

        if len(org_action_columns) > 0:
          reply_msgs.append(
            TemplateSendMessage(
              alt_text=u'事業所一覧',
              template=CarouselTemplate(
                columns = org_action_columns
              )
            )
          )
        else:
          reply_msgs.append(
            TextSendMessage(text = u'所属している事業所がありません。')
          )

      if len(reply_msgs) > 0:
        line_bot_api.reply_message(event.reply_token, reply_msgs)

Akerun一覧(画像カルーセルテンプレート)

Akerun一覧は横スクロール可能な画像カルーセルテンプレートで表示します。

画像カルーセルテンプレートは複数の画像を表示するテンプレートです。画像は横にスクロールして順番に表示できます。

事業所一覧のカルーセルのActionではPostbackTemplateActionを使ったので、Akerun一覧をTapした時のイベントはPostbackEventで拾います。

@handler.add(PostbackEvent)
def handle_postback_event(event):
  uid = get_id(event.source)
  data = json.loads(event.postback.data)
  cmd = data.get('cmd')
  reply_msgs = []

  if cmd == 'get_akeruns':
    akerun_token = akerun_temp_token
    org_id = data.get('org_id')
    akeruns = akerun_bot_get_akeruns(akerun_token, org_id)
    akerun_action_columns = []
    for ak in akeruns:
      ak_id = ak['id']
      ak_name = ak['name']
      ak_imgage = ak['image_url']
      ak_action = PostbackTemplateAction(
        label=org_name,
        data=json.dumps({'cmd': 'select_akerun', 'value': 'token={}&org_id={}&ak_id'.format(akerun_token, org_id, ak_id)})
      )
      akerun_action_columns.append(
        ImageCarouselColumn(
          image_url = ak_imgage,
          action = PostbackTemplateAction(
              # 12文字超える
              # label=ak_name + u'を解錠',
              label=ak_id + u'を解錠',
              data=json.dumps({'cmd': 'unlock', 'org_id': org_id, 'ak_id': ak_id})
          )
        )
      )

      # 最大10個
      if len(akerun_action_columns) >= 10:
        break
    
    if len(akerun_action_columns) > 0:
      reply_msgs.append(
        TemplateSendMessage(
          alt_text=u'Akerun一覧',
          template=ImageCarouselTemplate(
            columns=akerun_action_columns
          )
        )
      )
    else:
      reply_msgs.append(
        TextSendMessage(text = u'使用可能なAkerunがありません。')
      )
    
    if len(reply_msgs) > 0:
      line_bot_api.reply_message(event.reply_token, reply_msgs)

ただ、画像カルーセルテンプレートはカルーセルテンプレートと違って、画像が全領域に表示されて、Actionも一つしか設定できない(施錠か解錠しかできない)ので、カルーセルテンプレートの方が良さそうです。

上記のImageCarouselTemplateの部分をCarouselTemplateに変更します。

akerun_action_columns.append(
  CarouselColumn(
    title = ak_name,
    text = u'施錠解錠',
    thumbnail_image_url = ak_imgage,
    default_action = PostbackTemplateAction(
      label=u'解錠',
      data=json.dumps({'cmd': 'unlock', 'org_id': org_id, 'ak_id': ak_id})
    ),
    actions = [
      PostbackTemplateAction(
        label=u'施錠',
        data=json.dumps({'cmd': 'lock', 'org_id': org_id, 'ak_id': ak_id})
      ),
      PostbackTemplateAction(
        label=u'解錠',
        data=json.dumps({'cmd': 'unlock', 'org_id': org_id, 'ak_id': ak_id})
      )
    ]
  )
)


reply_msgs.append(
  TemplateSendMessage(
    alt_text=u'Akerun一覧',
    # template=ImageCarouselTemplate(
    #   columns=akerun_action_columns
    # )
    template=CarouselTemplate(
      columns = akerun_action_columns
    )
  )
)

施錠解錠

Akerun一覧のカルーセルのActionもPostbackTemplateActionを使ったので、施錠解錠をTapした時のイベントはPostbackEventで拾います。

施錠解錠の詳細について、その1とその2をご参照ください。

@handler.add(PostbackEvent)
def handle_postback_event(event):
  uid = get_id(event.source)
  data = json.loads(event.postback.data)
  cmd = data.get('cmd')
  reply_msgs = []

  if cmd == 'get_akeruns':
    # akerun一覧
  elif cmd == 'unlock':
    profile = line_bot_api.get_profile(event.source.user_id)
    name = profile.display_name
    push_msgs = []
    org_id = data.get('org_id')
    ak_id = data.get('ak_id')
    job_id = akerun_bot_unlock(akerun_temp_token, org_id, ak_id)
    reply_msgs.append(TextSendMessage(text=u'解錠'))
    push_msgs.append(TextSendMessage(text = u'{}さんが施錠しました'.format(name)))
    # todo:pooling -> push
    if len(reply_msgs) > 0:
      line_bot_api.reply_message(event.reply_token, reply_msgs)
    if len(push_msgs) > 0:
      line_bot_api.push_message(test_group_id, push_msgs)

合鍵一覧とユーザー一覧

合鍵一覧とユーザー一覧取得APIを使えば、後はAkerun一覧と同じ方法で表示できるので、詳細については割愛します。

まとめ

  • LINE Messaging APIではテキストだけではなく、カルーセルテンプレートのようなもっとリッチな表示が可能で、対応方法も簡単です。

  • シンプルに画像をTapした時のActionを実現する場合は画像カルーセルテンプレート、画像も表示しつつ複数のActionを実現する場合はカルーセルテンプレートを使えば良さそうです。

  • テンプレートMessage以外にも、画像、動画、音声、位置情報、イメージマップMessageとLayoutを自由にカスタマイズ可能なFlex Messageもあります。

  • Akerun APIには合鍵発行、ユーザー登録、ICカード登録、施錠解錠履歴取得などの機能もあるので、上記Messageを使ってみたいと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

便利な解錠方法-AppClip解錠

この記事は Akerun Advent Calendar 2021 - Qiita の1日目の記事です。

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はApp Clipでの解錠について説明します。

App Clipとは

iOS14から使えるApp Clipはアプリの一部分で、アプリの一部機能やコンテンツを必要な時に簡単に見つけて、すばやくアクセスし、体験することができます。

※弊社のAkerunの本体、あるいは近いところに専用QRコード/NFCタグを配置すれば、事前にAkerunアプリをインストールしなくても、1タッチ/1タップでの解錠が実現可能。

以下の特徴があります。

  • 高速で軽量(10MB以下)のため、すばやく起動可能。わずか数秒でダウンロード->インストール->使用まで可能
  • 通常の完全版アプリと同じ方法で開発可能(一部使用可能なiOS SDK制限あり)
  • 同じアプリに複数のApp Clipを作成可能
  • 完全版アプリのダウンロードを促すことが可能
  • 既に完全版アプリがインストールされている場合、完全版アプリが起動される
  • Apple Pay」、「Appleでサインイン」との相性が良い(ユーザー登録、合鍵発行など、良いUX実現可能)
  • App Clip専用の位置情報確認用APIを使用して、App Clipコード/NFCタグ/QRコードが所定場所で使用されているか確認可能
  • App Clipを見つけて、起動する方法は多数ある
    • Apple製のURL/NFCタグが埋め込まれているApp Clipコードをタップしたり、カメラでスキャンしてApp Clipを起動(以下のNFCタグ、QRコードが合体されている)
    • 通常のNFCタグにiPhoneをかざして起動。ロック画面からでも起動可能
    • QRコードを作成し、カメラでスキャンして起動
    • Safari App Banner(Smart App Banner)を使って、WebページのBannerをタップして起動
    • iMessage、Mailで共有されたLinkをタップして起動
    • マップ上のマーカーをタップして起動
    • 「最近追加した項目」カテゴリに表示されるので、そこから再起動
    • App Clip起動から8時間以内であれば、新しいPush通知から再起動可能

App Clipから解錠

URLが埋め込まれたQRコードをカメラでスキャンすると、下記にようにApp Clipがすぐ起動されます。ボタン(Labelはカスタタイズ可能)をタップすると、指定画面に遷移され、解錠処理が実行されます。

App Clipの実装方法

App Clipの追加

  • 既存アプリにApp ClipのTargetを新規追加

  • 「Next」画面でProject名(例えば、既存アプリ名+AppClip)を入力し、UIKit AppかSwiftUI Appを選択

App Clip起動(XXボタンをタップ)した時の処理

App Clipから起動された場合、NSUserActivityがイベントに渡されるので、NSUserActivityからURLを取得、解析後、指定画面に遷移し、処理時実行します。 ※URL例:https://ドメイン/unlock/door?id=1001

  • UIKit Appの場合(Universal Linksと同じ方法)
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
        guard let url = userActivity.webpageURL, let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return false }
        // componentsからURLパスを解析、Queryなどを抽出し、関連画面に遷移、処理時実行
    }
}
  • SwiftUI Appの場合、Universal Linksと異なって、onOpenURLではなくonContinueUserActivityが呼ばれるので、表示したい画面でイベントを拾う
struct ContentView: View {
    var body: some View {
        NavigationView {
            ...
        }
        .onContinueUserActivity(NSUserActivityTypeBrowsingWeb, perform: handleUserActivity(_:))
    }
}

extension ContentView {
    func handleUserActivity(_ userActivity: NSUserActivity) {
        guard
            let incomingURL = userActivity.webpageURL,
            let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
            let queryItems = components.queryItems
        else {
            return
        }
        
        if let id = queryItems.first(where: { $0.name == "id" })?.value {
            // idの鍵/ドアの解錠処理を行う
        }
    }
}
  • UIKitのSceneDelegateライフサイクルの場合、Univeral Linksと同じ方法で対応
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    // Univeral Links / AppClip
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
        guard
            let incomingURL = userActivity.webpageURL,
            let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
            let queryItems = components.queryItems
        else {
            return
        }
        
        if let id = queryItems.first(where: { $0.name == "id" })?.value {
            // idの鍵/ドアの解錠処理を行う
        }
    }
}

解錠処理の実装

Akerun公開APIで合鍵発行、解錠が可能です。 具体的な実装は割愛します。弊社のAkerun Developersと下記の関連記事をご参照ください。

developers.akerun.com

akerun.hateblo.jp

akerun.hateblo.jp

アプリ本体の対応

既にアプリ本体がインストールされている場合、App Clipではなくアプリ本体が起動されるので、アプリ本体にもUniveral Linksで同じ対応を入れる必要があります。

ドメインの登録

Universal Linksと同じくサーバーのドメインを登録する必要があります。

  • json形式(.json拡張子不要)のapple-app-site-associationファイルを.well-knownに配置し、直接Getできるようにする(Redirectはダメ)

  • apple-app-site-associationファイルにApp ClipアプリのBundle Identifyを追加

{
    "appclips": {
        "apps": [
            "teamId.appId.Clip",
            "teamId.appId.Stg.Clip" ←Staging/Debug版アプリがあれば
        ]
    },
    "applinks": { ←アプリ本体Universal Links用
        "apps": [],
        "details": [
            {
                "appID": "teamId.appId",
                "paths": [
                    "unlock/*", ←解錠
                    "receive/*" ←合鍵受取
                ]
            },
            {
                "appID": "teamId.appId", ←Staging/Debug版アプリのID
                "paths": [
                    "unlock/*", 
                    "receive/*"
                ]
            }
        ]
    }
}
  • アプリ本体とApp ClipアプリのCapabilitiesにAssociated Domainsを追加し、ドメインを設定(アプリ本体にUniversal Links用のドメインも追加)

AppStoreConnectでの設定

App Clipが起動された時の画面表示を設定する場合、アプリをArchiveし、AppStroeConnectにアップロードしてから設定を行います。

  • 基本設定:背景画像、サブタイトル、アクション(ボタンラベル)を設定

  • NFCタグ、QRコードに埋め込むURLを作成したり、App Clipの詳細設定をおこな場合、「高度な体験を編集」をクリックする

apple-app-site-associationを設定すると、エラーは消える。

App ClipのDebug

指定URLから正常にApp Clipが起動されるか以下の方法でDebug可能です。URLのLinkを直接Tapして起動、あるいはNFCタグ/QRコードからの起動が可能です。

Schemeの設定

App ClipのScheme(Run->Arguments->Environment Variables)にテストURLを設定します。

iPhoneでLocal Experiencesを登録

iPhoneの設定->Developer->Local ExperiencesにURLごとの設定を追加します。複数のURLの登録ができ、以下の項目が設定可能です。

  • URL Prefix:Schemeに設定したURLを設定
  • Bundle ID:App ClipのBundle IDを設定
  • タイトル、サブタイトル:任意の文字列
  • アクション(ボタンラベル):一覧から選択
  • 背景画像:任意のPNG/JPEG画像。1800x1200ピクセルで、できるだけ小さいサイズの画像の方が良い

Debugを実施

以下の手順でDebugを実施します。

  • XcodeからApp Clipを起動
  • 上記のURLをSafariで開く。QRコードNFCタグからも起動可能
    • URLのQRコードを生成し、カメラからスキャン
    • 市販のNFCタグを購入し、URLを書き込む
  • URLのLinkをTap(あるいは、QRコードをスキャン/iPhoneNFCタグをタップ)すると、App Clipが起動される

その他

解錠だけではなく、「Appleでサインイン」を使えば、ユーザー登録、合鍵登録も簡単にできそうです。

  • ユーザー登録、合鍵登録用URLを発行する
  • ユーザーにURLを共有する
    • 直接Linkを共有
    • QRコードを共有、あるいは、印刷して指定場所に配置
    • NFCタグにURLを書き込んで、指定場所に配置
  • ユーザーはLinkをTap/QRコードをスキャン/iPhoneNFCタグをTapすることでApp Clipアプリを素早く起動(事前にアプリのインストールは不要)
  • Appleでサインイン」でサインインし、ユーザーを登録/合鍵を受け取る
    • ユーザー登録する場合、ユーザーのApple IDを使って新規ユーザー登録を行う
      • iPhoneで生体認証(顔認証、指紋認証
      • 登録に必要な情報(メールアドレス、Tokenなど)をサーバーに送信し、ユーザー登録を行う
    • 合鍵受け取る場合、「Appleでサインイン」でサインインし、合鍵を受け取る
      • まだユーザー登録されていない場合、ユーザー登録->合鍵を受け取る
      • 登録済みの場合、Apple IDでサインインし、合鍵を受け取る

まとめ

App Clipはユーザーにとても優れたUXを提供できる機能です。

URLベースになっているので、実装上Universal Linksとはそれほど差異がありません。また、設定もシンプルなので、どんどん活用して行きたいですね。

次回は、Widgetについて書こうと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

便利な解錠方法-NSUserActivity-SiriShortcuts解錠

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はNSUserActivityを利用したSiri Shortcutsでの解錠について説明します。

NSUserActivityとは

抽象的な言い方だと、NSUserActivityは「ある時点でのアプリの状態」を表現します。

ユーザーがアプリを使って、コンテンツの表示、ドキュメントの編集、Webページの表示、ビデオの視聴などを行った時の状態(関連情報)をNSUserActivityのオブジェクトとして保存すれば、再度アプリが起動された時、上記の状態から復元することができます。

一例として、以下が可能です。詳細について、今後順次紹介します。

  • Shortcutsアプリに登録したShortcutのActionを実行した時、指定画面に遷移させたり、機能を実現可能
  • 音声でアプリを起動し、指定画面に遷移させたり、機能を実現可能
  • Hand-off/Hand-freeでMacで途中までの作業をそのままシームレスでiPhoneで作業再開可能
  • 端末内での検索可能
  • Webと連携すれば、Global検索も可能
  • iOS15からのQuickNoteでも使われている(まだ使ったことない)

Siri Shortcutsの使い方(解錠方法)

  • Siri Shortcutsに登録すれば、使用頻度が上がる時、Siriからの提案にShortcut(解錠)が表示され、1Tapで解錠可能

※Siriからの提案:Home画面を下にSwipe、ロック画面で左にSwipeした時表示される検索窓(Spotlight)の一番上位に表示される

  • Siriを呼び出して、音声から解錠可能。登録済みの音声フレーズ「あけるん」を言えば、アプリが起動され、指定画面に遷移し、対象ドアを解錠できる。

※端末によるかも知れないが、 Home画面(生体認証後のLock画面)でサイドボタンを2回ClickでSiriを呼び出せる。もちろん「Hey Siri」でもOK

  • オートメーションを作れば、「xxx」の時「解錠」することも可能。最後に説明する。

Siri Shortcutsの実装方法

Siri Shortcutsの登録

Siri Shortcutsの登録方法には、NSUserActivityを利用した方法とIntentsを利用する方法がありますが、今回は前者について説明します。Intentsについてはまた別の機会でご紹介します。

NSUserActivityTypesを定義

Info.plistのNSUserActivityTypesにNSUserActivity作成に必要なIDを定義します。 一意であれば何でも良いですが、アプリのbundleId+機能ごとの文字列で良いと思います。

<key>NSUserActivityTypes</key>
<array>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER).unlock-door</string>
</array>

アクションを行うNSUserActivityオブジェクトを生成

  • オブジェクト生成
class SKUserActivity {
    enum ActivityType: String {
        case doorUnlock = "unlock-door"
        case keyShare = "share-key"
        
        var id: String {
            // TODO: add team id (info.plist)
            return (Bundle.main.bundleIdentifier ?? "") + "." + self.rawValue
        }
    }
    class func create(_ type: ActivityType, _ title: String, _ phrase: String? = nil) -> NSUserActivity {
        // Info.plistに登録したID
        let userActivity = NSUserActivity(activityType: type.id)
        userActivity.persistentIdentifier = type.id
        userActivity.title = title
        // 音声フレーズ(コマンド)。Siri Shortcutsに登録時変更可能
        userActivity.suggestedInvocationPhrase = phrase
        // Siriからの提案を可能にする
        userActivity.isEligibleForPrediction = true
        // 検索可能にする
        userActivity.isEligibleForSearch = true
        return userActivity
    }
}
  • Spotlight検索結果に表示する場合
let attributeSet = CSSearchableItemAttributeSet(contentType: .image)
// 実際userActivity.titleが表示され、このtitleは表示されない
attributeSet.title = "Test"
attributeSet.contentDescription = "Unlock"
userActivity.contentAttributeSet = attributeSet

アクションで開く画面のUIViewControllerにオブジェクトを設定

NSUserActivityのオブジェクトを画面(UIViewController)に紐づけることで、Shortcutsアプリに表示され、登録可能になります。(一度アプリを起動し、該当画面に遷移する必要があるようです)

override func viewDidLoad() {
    super.viewDidLoad()
    self.userActivity = self.siriUserActivity
}

Siri Shortcutsボタンを追加

ユーザーが簡単にSiri Shortcutsを登録できるように、画面に専用ボタンを配置することも可能です。

UIKitの場合、上記のUIViewControllerにINUIAddVoiceShortcutButtonを配置すれば良いですが、SwiftUIの場合、INUIAddVoiceShortcutButtonそのまま使えないので、UIViewControllerRepresentableを使って、UIKit->SwiftUIパーツに変換する必要があります。

  • Siriボタンのパーツを作成
import Foundation
import SwiftUI
import Intents

struct SiriButton: UIViewControllerRepresentable {
    private let userActivity: NSUserActivity
    
    init(userActivity: NSUserActivity) {
        self.userActivity = userActivity
    }
    
    func makeUIViewController(context: Context) -> some UIViewController {
        return SiriShortcutViewController(userActivity: self.userActivity)
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        
    }
}
  • SiriShortcutViewControllerを作成し、INUIAddVoiceShortcutButtonを配置する
import Foundation
import SwiftUI
import IntentsUI

class SiriShortcutViewController: UIViewController {
    private let siriUserActivity: NSUserActivity
    
    init(userActivity: NSUserActivity) {
        self.siriUserActivity = userActivity
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.userActivity = self.siriUserActivity
        // これはなくても良い?(詳細不明)
        self.userActivity?.becomeCurrent()
        
        let siriButton = INUIAddVoiceShortcutButton(style: .blackOutline)
        siriButton.shortcut = INShortcut(userActivity: self.siriUserActivity)
        siriButton.delegate = self
        siriButton.translatesAutoresizingMaskIntoConstraints = false
        
        self.view.addSubview(siriButton)
        self.view.centerXAnchor.constraint(equalTo: siriButton.centerXAnchor).isActive = true
        self.view.centerYAnchor.constraint(equalTo: siriButton.centerYAnchor).isActive = true
    }
}
  • SwiftUIの画面にSiriボタンを配置
struct TestView: View {
    var body: some View {
        VStack() {
            SiriButton(userActivity: SKUserActivity.create(.doorUnlock, "unlock", "あけるん"))
        }
    }
}

SiriボタンでSiri Shortcutsに追加する時の処理

Siri Shortcutsに新規追加、編集、削除時の画面表示するための最小限の実装になります。

extension SiriShortcutViewController: INUIAddVoiceShortcutButtonDelegate {
    func present(_ addVoiceShortcutViewController: INUIAddVoiceShortcutViewController, for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
        addVoiceShortcutViewController.delegate = self
//        addVoiceShortcutViewController.modalPresentationStyle = .formSheet
        // Shortcut画面を閉じる時の動作検知
        addVoiceShortcutViewController.presentationController?.delegate = self
        
        self.present(addVoiceShortcutViewController, animated: true, completion: nil)
    }
    
    func present(_ editVoiceShortcutViewController: INUIEditVoiceShortcutViewController, for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
        editVoiceShortcutViewController.delegate = self
        // Shortcut画面を閉じる時の動作検知
        editVoiceShortcutViewController.presentationController?.delegate = self
        
        self.present(editVoiceShortcutViewController, animated: true, completion: nil)
    }
}

extension SiriShortcutViewController: INUIAddVoiceShortcutViewControllerDelegate {
    func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController, didFinishWith voiceShortcut: INVoiceShortcut?, error: Error?) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
        controller.dismiss(animated: true, completion: nil)
    }
}

extension SiriShortcutViewController: INUIEditVoiceShortcutViewControllerDelegate {
    func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController, didUpdate voiceShortcut: INVoiceShortcut?, error: Error?) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController, didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
        controller.dismiss(animated: true, completion: nil)
    }
    
    func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
        controller.dismiss(animated: true, completion: nil)
    }
}

extension SiriShortcutViewController: UIAdaptivePresentationControllerDelegate {
    func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
        // Shortcut will be closed
    }
    
    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
        // Shortcut was closed
    }
}

Siriに追加したら、Shortcutsアプリにも表示されます。

直接Shortcutsアプリから登録する場合、ある程度アプリを使わないと表示されないようです。

下記のAppをTapし、表示されるアプリ一覧には表示されません。

Siri Shortcutsから解錠

Shortcut、Siri音声コマンド、Spotlight検索窓のSiriからの提案に表示されているShortcutなどから1Tapでアプリを起動し、解錠することが可能です。 下記でアクションをハンドリングします。

  • UIKitアプリの場合、以下のDelegate関数を実装
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard let userInfo = userActivity.userInfo else {
        return false
    }
    guard userActivity.activityType == SKUserActivity.ActivityType.doorUnlock.id else {
        return false
    }
    // userInfoから鍵/ドアのIDを取得し、関連画面に遷移し、解錠処理を行う
    return true
}
  • SwiftUIアプリの場合、表示する画面で以下を実装
struct ContentView: View {
    var body: some View {
        NavigationView {
            ...
        }
        .onContinueUserActivity(SKUserActivity.ActivityType.doorUnlock.id, perform: handleShortcut(_:))
    }
}

extension ContentView {
    func handleShortcut(_ userActivity: NSUserActivity) {
        if let userInfo = userActivity.userInfo {
            // userInfoから鍵/ドアのIDを取得し、解錠処理を行う
            print("userInfo: \(userInfo)")
        }
    }

解錠処理の実装

Akerun公開APIで合鍵発行、解錠が可能です。 具体的な実装は割愛します。弊社のAkerun Developersと下記の関連記事をご参照ください。

developers.akerun.com

akerun.hateblo.jp

akerun.hateblo.jp

Siri Shortcutsを使った便利機能(拡張版解錠方法)

Shortcutsアプリからオートメーションを作成すれば、色んな方法で解錠することができます。

iPhone背面タップして解錠

iOS設定->アクセシビリティ->タッチ->背面タップからアプリのShortcutを登録します。

f:id:photosynth-inc:20210906214945g:plain

QRコードで解錠

ShortcutsアプリからQRスキャンしてアプリを起動するShortcutを作成し、Home画面に追加します。

  • ギャラリーからマイショートカットにQRコードスキャンを追加

  • 既存アクションを削除し、自作アプリを登録

f:id:photosynth-inc:20210906215027g:plain

NFCタグで解錠

新規オートメーションを作成し、NFCスキャンをトリガーに、アプリをアクションに登録します。

f:id:photosynth-inc:20210906215326g:plain

まとめ

NSUserActivityではいろんなことができます。Webと連携することも可能です(別の機会で書く予定)。

その一つのSiri Shortcutsを実装すれば、iOS純正アプリShortcutsを使って無限な便利な機能を提供できそうです。

次回は、Widget、AppClipについて書こうと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

便利な解錠方法-HomeScreenQuickAction解錠

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はHomeScreenQuickActionでの解錠について説明します。

3D TouchのHomeScreenQuickActionとは

HomeScreenQuickActionの使い方(解錠方法)

iOS13以降で3D Touch対応端末で、アプリのHomeアイコンを強く押し込んだ時、以下の画面が表示されます。HomeScreenQuickActionに解錠Actionを登録した場合、表示されている鍵/ドア一覧から鍵/ドアをTapすると、アプリが画面が表示され、解錠処理が行われます。

HomeScreenQuickActionの実装方法

HomeScreenQuickActionへのAction登録

静的Actionの登録

アプリのInfo.plistにAction関連ShortcutItemsを追加します。

Key name 説明
UIApplicationShortcutItemType (必須) アクション種別
UIApplicationShortcutItemTitle (必須) Home Screen 上に表示されるタイトル
UIApplicationShortcutItemSubtitle Home Screen 上に表示されるサブタイトル
UIApplicationShortcutItemIconType アイコンの種別(システム指定アイコン)
UIApplicationShortcutItemIconFile カスタムアイコン
UIApplicationShortcutItemUserInfo 起動時に渡されるデータ
UIApplicationShortcutItemIconSymbolName SF Symbol

※IconType/IconFile/IconSymbolNameで画像を指定した場合、以下の順で適用される。

  1. UIApplicationShortcutItemIconSymbolName
  2. UIApplicationShortcutItemIconFile
  3. UIApplicationShortcutItemIconType

Info.plist

<key>UIApplicationShortcutItems</key>
<array>
    <dict>
        <key>UIApplicationShortcutItemType</key>
        <string>SearchAction</string>
        <key>UIApplicationShortcutItemIconType</key>
        <string>UIApplicationShortcutIconTypeSearch</string>
        <key>UIApplicationShortcutItemTitle</key>
        <string>Search</string>
        <key>UIApplicationShortcutItemSubtitle</key>
        <string>Search for an item</string>
    </dict>
    <dict>
        <key>UIApplicationShortcutItemType</key>
        <string>ShareAction</string>
        <key>UIApplicationShortcutItemIconType</key>
        <string>UIApplicationShortcutIconTypeShare</string>
        <key>UIApplicationShortcutItemTitle</key>
        <string>Share</string>
        <key>UIApplicationShortcutItemSubtitle</key>
        <string>Share an item</string>
    </dict>
</array>

動的Actionの登録

let icon = UIApplicationShortcutIcon(templateImageName: "shortcut-unlock")
let item = UIMutableApplicationShortcutItem(
    type: key.id,
    localizedTitle: key.name,
    localizedSubtitle: "sub",
    icon: icon,
    userInfo: nil // 必要な情報があればここに追加([String : NSSecureCoding])
)
// 複数のitemを設定できるが、システムが画面サイズに合わせて最適な数を表示(大体最大4個?)
UIApplication.shared.shortcutItems = [item]

Tapした時の処理

Tapした時のイベント処理はUIKit AppとSwiftUI App、そしてSceneを使うかどうかで処理が異なるので、それぞれの処理について説明します。

UIKit Appの場合

  • Sceneを使わない場合
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
    // typeから鍵/ドアのIDを取得し、画面表示、解錠処理を行う
    actionShortcut(shortcutItem.type)
    completionHandler(true)
}
  • Sceneを使う場合、且つ、アプリが起動されていない場合
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let shortcutItem = connectionOptions.shortcutItem {
        handleShortcutItem(shortcutItem)
    }
}

func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) {
    let id = shortcutItem.type

    // DeepLinks(Universal Links or Custom URL)を使って、関連画面に遷移
    if let url = URL(string: "xxx/" + Id) {
        openURL(url)
        // UIApplication.shared.open(url)
    }
}

通常のDebugの場合、既にアプリが起動されているので、初回起動時のDebugは下記の方法で行います。 Schemeを下記通りに設定し、必要なところにBreakPointを入れます。Runを行い、HomeScreenをTapしてアプリを起動すれば、Debugが可能になります。

  • Sceneを使う場合、且つ、アプリが起動されている場合
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
    handleShortcutItem(shortcutItem)
}

SwiftUI Appの場合

デフォルトでAppDelegateとSceneDelegateがないので、カスタムScene Delegateを作成します。 Actionを受信後、openURL()でDeepLinkを送って、対象画面で処理を行います。

@main
struct SmaKeyApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: AppViewModel())
        }
    }
}

class AppDelegate: UIResponder, UIApplicationDelegate {
    // MARK: UISceneSession Lifecycle
    
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Info.plistに"Default Configuration"(名前は任意)を設定し、nameに指定した場合、CustomSceneDelegateは不要かも
        let sceneConfiguration = UISceneConfiguration(name: "Custom Configuration", sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = CustomSceneDelegate.self
        
        return sceneConfiguration
    }
    
    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}

class CustomSceneDelegate: UIResponder, UIWindowSceneDelegate {
    @Environment(\.openURL) var openURL
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let shortcutItem = connectionOptions.shortcutItem {
            handleShortcutItem(shortcutItem)
        }
    }

    func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
        handleShortcutItem(shortcutItem)
        completionHandler(true)
    }
}

extension CustomSceneDelegate {
    // Home Screen Quick Action - Shortcut
    func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) {
        let id = shortcutItem.type
        if let url = URL(string: "xxx/" + id) {
            openURL(url)
        }
    }
}

解錠処理の実装

Akerun公開APIで合鍵発行、解錠が可能です。 具体的な実装は割愛します。弊社のAkerun Developersと下記の関連記事をご参照ください。

developers.akerun.com

akerun.hateblo.jp

akerun.hateblo.jp

まとめ

実装方法はいくつかありますが、アプリ種類に合わせれば簡単に実装できます。

次回は、NSUserActivity、SiriShortcutsについて書こうと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

便利な解錠方法-Spotlight解錠

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はSpotlightでの解錠について説明します。

Spotlight(検索)とは

Spotlightの使い方

Spotlight(検索)について普段から活用している人も多いと思いますが、ロック画面やホーム画面を右スワイプ、あるいは、ホーム画面を下にスワイプした時に出てくる「検索窓」のことです。

  • 右にスワイプ時に表示される検索窓(をTap)

  • 下にスワイプ時に表示される検索窓

Spotlightでの解錠方法

  • Spotlight検索画面でキーワード(アプリで設定された「解錠」文字列、ドア名など)を入力すれば、アプリ経由で解錠できる鍵/ドア一覧が表示される。
    • 鍵/ドア名を「Home」のようは一般的なキーワードにした場合、関係ない検索結果もたくさん表示されてしまうので、アプリ名か独自キーワードで工夫する必要がある。
    • 頻繁に使われている鍵/ドアの場合、自動的に最上位に表示されるので(iOSの独自アルゴリズムなので、表示順はアプリで制御できない)、使えば使えるほど便利になる。

  • 一覧に表示されている鍵/ドアをTapすれば、アプリが表示され、解錠まで1Tapで実現可能。

CoreSpotlightの実装

1Tapで解錠するには、iOSのCoreSpotlightのFrameworkを使って実装する必要があります。

Spotlightへの登録

検索する情報をItemとしてSpotlightのIndexに登録する必要があります。 Item数は数千個までなら最適に機能できるようです。

Core Spotlight works best when you have no more than a few thousand items.

  • CoreSpotlightを使うので、importする
import CoreSpotlight
  • 検索項目ごとにCSSearchableItemAttributeSetのインスタンを生成し、それそれのインスタンスのpropertiesを設定する。例えば、複数の鍵/ドアを持っている場合、それぞれの鍵/ドアのCSSearchableItemAttributeSetのインスタンを生成。
    • iOS13までinit(itemContentType: String)、iOS14からはinit(contentType: UTType)を使ってインスタンスを生成
    • シンプルに検索結果にtitle、画像を表示する場合、.imageのcontentTypeを指定する
    • 基本propertiesとして、title、contentDescription、keywords、thumbnailDataを設定する

  • keywordsには検索しやすい、引っかかりやすいキーワードを複数指定する。例えば、アプリ名、「解錠」などアプリ専用単語、鍵/ドア名など。
let attributeSet = CSSearchableItemAttributeSet(contentType: .image)
attributeSet.title = "解錠 \(key.name)"
attributeSet.contentDescription = "\(key.name)を解錠します"
attributeSet.keywords = ["XXXKey", "解錠", key.name]
attributeSet.thumbnailData = thumbnail
  • 鍵/ドアごとの一意ID(uniqueIdentifier)を指定したCSSearchableItemのインスタンスを生成し、上記のCSSearchableItemAttributeSetのインスタンスを関連付ける。
    • サーバーから取得した鍵/ドアのidが一意になっているので、そのまま指定すればOK
    • uniqueIdentifierに指定されたIDはCSSearchableItemActivityIdentifierをキーとしてNSUserActivityのuserInfo登録されるので、検索結果から鍵/ドアの識別に使われる。
    • groupで複数のItemを管理する場合、domainIdentifierも指定する。com.myCompany.myContentType(domainの逆順)の形式の任意の一意文字列を指定すればOK。
// ble/遠隔を区別する場合、prefixなどをつける
let id = "ble_" + key.id
let domainId = "xxx.yyy.CoreSpotlight.key"
let item = CSSearchableItem(uniqueIdentifier: id, domainIdentifier: domainId, attributeSet: attributeSet)
  • IndexにItemを登録する
    • 複数の鍵/ドアがある場合、配列で一括登録
CSSearchableIndex.default().indexSearchableItems([item]) { (error) in
}

Item登録/削除タイミング

アプリにログインし、サーバーから鍵/ドア一覧情報を取得する度にItemを登録し(2回目以降の場合、既存Itemを一度削除してから再登録)、ログアウトした時全てのItemを削除します。

  • 鍵/ドア情報が変わる可能性があるので、一覧を取得する度に再登録する(一度全て削除してから登録)
    • 削除した場合、今までの評価とランキング情報がなくなるかも?(詳細不明)
    • 更新方法はないようなので、indexSearchableItemsに同じIDを渡して再登録
  • CSSearchableIndexDelegateを実装しても良いかも
  • Batch処理も可能

https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/AppContent.html#//apple_ref/doc/uid/TP40016308-CH7-SW1

Spotlight検索結果からItemをTapした時の解錠処理(UIKit AppとSwiftUI App)

Tapした時のイベント処理はUIKit AppとSwiftUI Appでの処理が異なるので、それぞれの処理について説明します。

UIKit Appの場合

AppDelegateの以下のメソッドでイベントを拾って処理を行います。

  • Itemに指定されたuniqueIdentifierがNSUserActivityのuserInfoのCSSearchableItemActivityIdentifier(定義値:kCSSearchableItemActivityIdentifier文字列)キーの値として渡されるので、そこから鍵/ドアをIDを取得し、解錠処理を行う。
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard let userInfo = userActivity.userInfo else {
        return false
    }
    guard userActivity.activityType == CSSearchableItemActionType else {
        return false
    }
    guard let itemId = userInfo[CSSearchableItemActivityIdentifier] as? String else {
        return false
    }

    // itemIdから鍵/ドアのIDを取得し、解錠処理を行う

    return true
}

SwiftUI Appの場合

ViewのonContinueUserActivityメソッドでイベントを拾って処理を行います。

  • 基本的に解錠処理を表示する画面でイベントを拾えばOK。例えばTabViewの場合は下記になる。
struct ContentView: View {
    var body: some View {
        TabView(selection: $selection) {
            ...
        }
        .onContinueUserActivity(CSSearchableItemActionType, perform: handleSpotlight(_:))
    }
    
    func handleSpotlight(_ userActivity: NSUserActivity) {
        guard let userInfo = userActivity.userInfo else {
            return
        }
        guard let itemId = userInfo[CSSearchableItemActivityIdentifier] as? String else {
            return
        }
        // itemIdから鍵/ドアのIDを取得し、解錠処理を行う
    }
}

解錠処理の実装

Akerun公開APIで合鍵発行、解錠が可能です。 具体的な実装は割愛します。弊社のAkerun Developersと下記の関連記事をご参照ください。

developers.akerun.com

akerun.hateblo.jp

akerun.hateblo.jp

まとめ

実装も簡単な便利な機能なので、積極的にSpotlightを使いましょう。

次回は、HomeScreenQuickActionについて書こうと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

プロフェッショナルマネジメント

この記事は Akerun Advent Calendar 2020 - Qiita の25日目の記事です。

2020年6月にフォトシンス に入社して、WEBグループのマネージャをしていますNonです。

2000年に大手ベンダーのSEとして社会人をスタートし、今年で21年目となりますが、ベンチャーから大手企業まで、複数の会社を通じて、大小様々なプロジェクトを経験しております。

ここ10数年は、主にエンジニアチームのマネジメントに従事してきて、マネジメントの難しさを地肌で感じると共に、色々な案件を通じてマネジメントスキルを磨くことができました。

今回は、マネジメントに携わる上で、私が重要視していることを少しお話できればと思います。

マネジメントとは?

マネジメントとは?聞かれた場合、皆さんはなんと答えるでしょうか?

アメリカの経営学者P.F.ドラッカーによると下記のように述べています。

  • マネジメント:組織に成果を上げさせるための道具、機能、機関
  • マネージャー:組織の成果に責任を持つ者

【出典】P.F.ドラッカー(1999)「明日を支配するもの 21世紀のマネジメント革命」ダイヤモンド社

組織の成果に注目し、マネージャーは、組織の成果に責任を持たなければいけません。

つまり、メンバーの動機付け、評価、育成などを通じて組織を作っていく役割を求められます。

マネジメントの課題

マネジメントは難しいとよく言われますが、具体的にどういった課題があるのでしょうか?

  • 部下の育成が出来ない、仕事を任せられない
  • 組織目標やビジョンが打ち出せず組織をまとめられない
  • 組織間の調整がうまくできない
  • 上司と部下の間に挟まれる立場になり判断に困る

このようなことを良く耳にします。

一見どれも重要な課題に感じますが、引き起こされる結果に注目する必要があります。

これらが原因となって、組織の目標を達成できないということが最も重要なポイントです。

マネージャーは、組織の成果に責任を持つ者なので、組織の目標を達成すべく、様々な手段を講じる必要があります。

マネジメントに求められるスキル

マネージャーは自分の業務だけではなく、メンバーの業務と組織を管理する能力が問われます。

プレイヤーからマネージャーに昇進すると、今までとは違う壁にぶつかります。

部下を育てられずにいると、いつまで経っても自分自身の業務が減りません。

結果、プレイヤーとしての負荷が高くなってしまい、マネジメントの比率が低くなってしまいます。

この問題を解決するためには、マネジメントスキルを高める必要があります。

では、マネージャーには、どんなスキルが求められるのでしょうか?

私のこれまでの経験から14のスキルに整理してみました。

マネジメントに求められる14のスキル
1 見積管理 要件からタスクを洗い出し、メンバーのスキル状況も考慮して、適切な見積もりができるか。
2 業務管理 「何を(What)」、「いつまでに(When)」、「どうやって(How)」やるかという具体的な指示をメンバー与え、その過程や結果を管理・監督できるか。
3 指導・教育 目標設定を適切に行い、教育プランを考えて部下をゴールまで導いて行けるか。
4 業務改善 日々の業務をこなすだけではなく、課題を解決したり、仕組み化したりして業務効率をあげることができるか。
5 リスク対策 リスクの洗い出しや対策準備、リスク発生後の適切な処置が迅速に行えるか
6 課題の発見と解決 業務における課題や問題点を見つけだし、その解決策を提案できるか。
7 トラブル収束 問題が起きた際にその内容に応じて迅速かつ柔軟に対応できるか。
8 組織ビルディング 組織に必要な役割を明確にし、規模に応じた組織の体制作りができるか。
9 労務管理 残業、休日出勤などの管理を徹底し、健康面を第一に考えた判断ができるか。
10 経営理念の周知・徹底 経営者の目指す方向を正確にメンバーに伝え、同じ方向に導けるか。
11 予算管理 収支・収益を意識し、より少ない投資で最大限の利益をあげられるか。
12 業務成果の適切な評価 メンバーの成果に対して、主観的ではなく客観的に評価ができるか。
13 適材適所への配置 メンバーのスキルや強み・弱みを把握し、育成の観点も含めてリソース配置を行えるか。
14 戦略立案 中長期の戦略や方針を正しく策定できるか。

マネージャになると、大手の企業ではマネジメント研修などもありますが、ベンチャー企業など、教育制度の整ってない企業では、いきなりマネージャーをさせられことも少なくありません。

マネージャーは、プロジェクトの進捗管理をしたり、リソースの管理をしたりすることに、フォーカスされがちですが、もっとたくさんのことを求められます。

組織の成果を出すためには、上記のようなスキルを身につけて、業務を遂行していかなければなりませんので、マネージャーになったら自身の役割と必要なスキルを確認しましょう。

プロジェクトマネジメントで重要なこと

ここまでは、マネジメントという少し広い範囲でのお話をしましたが、もう少しスコープを絞って、プロジェクトマネジメントについても見ていきましょう。

私は、プロジェクトマネジメント目的は、チームパフォーマンスの最大化と考えています。 限られたリソースの中でどれだけパフォーマンスを最大化できるかが、プロマネに求められることだと思います。

プロジェクトマネジメントで重要な三要素
  • タスクを適切に振り分けること
  • トラブルを未然に防ぐこと
  • 仕組み化を徹底し、無駄な時間を減らすこと

簡単にいうとムリ・ムダ・ムラの徹底排除です。

タスクを適切に振り分けること

タスクを漏れなく洗い出し、期日やメンバーの育成などを踏まえて、適切にタスクを振り分けていかなければなりません。経験値の高い人にばかり頼っていては、中長期的に見たときに、チームとしてのパフォーマンスは最大化されません。

トラブルを未然に防ぐこと

トラブルは、どんなにケアしていても起こってしまいますが、トラブルが少なければ少ないほど、計画はスムーズに進みます。ありとあらゆるリスクを想定して、未然に防ぐ努力をしましょう。

仕組み化を徹底し、無駄な時間を減らすこと

同じような作業を、他の人が繰り返していることは、色々な現場でよく見かけます。テンプレート化したり、プログラムで自動化したりすることで、大幅に作業時間を減らすことができます。少しでも、無駄を削減できるように、常に仕組み化できないか考えることが重要です。

また、プロジェクトマネジメントをする上での心構えとして下記の点に注意しています。

  • 自分ができるから、他人もできるはずという考えを捨てること
  • 自分でやったほうが早いと思っても、自身の手を出さないこと
  • チームが持っている力以上のことを求めないこと
  • チームの課題は、チーム全体で取り組むこと

マネージャがやるべきことは、方向性を示すことジャッジメントであり、自分自身で作業をすべきではありません。

一方的に指示を出すのではなく、メンバーの思考停止が起きないように、チームメンバーで決めたことをやらせましょう。

ただし、メンバーの進めようとしてる手段に対して、リスクがある場合には、リスク観点について質問をすることで、リスクの存在と対策をメンバー自身で考えられるようにリードすることも忘れてはいけません。

現在のチーム力を把握し、それをベースにプロジェクトの計画を立て、中長期的にチームの戦力を強化していくことが重要です。

目先の案件に捉われていると、いつまでたっても開発スピードが上がらなかったり、やりたいことができないなど、負のループに陥ってしまいます。

プロジェクトマネジメントにおける仕組み化

プロジェクトマネジメントの手法は色々ありますが、一番重要なのは、タスクの可視化課題の可視化だと思います。

やらなければならないことが全て洗い出せていること、適切な優先順位で作業を行なっていることをマネージャーはチェックしなければなりません。 また、課題を特定しない段階で、手段を考えても課題は解決しませんが、本質的な課題が見つかれば、改善策を考えるのはそんなに難しくありません。

2つの可視化をする上で、私が必ず行なっている仕組みがあります。

デイリーミーティング
  • 昨日やったこと、今日やることをチケット管理ツールをみながらメンバーに共有する
  • 全てのタスクはチケット化する
  • タスクは、担当者、工数、期限を設定する
  • タスクは、1日単位など細かいものに分解する
  • タスクは、完了定義を明確にする
  • 正しい優先順位でタスクに着手しているか確認する
  • 今抱えている課題を共有する
週次KPTミーティング
  • 毎週KPTを実施する
  • プロジェクトメンバーの参加を必須とする
  • 課題(Problem)についてメンバー全員で議論する
  • 本質的な課題にたどり着くまでブレークダウンする
  • Tryはメンバー全員が合意して決定する
  • Tryは1週間でできることを定義する
  • 残った課題は、積み上げないで捨てる

KPTについてわかりやすい記事がありましたので共有します。

この二つのミーティングをやり続けるだけで、プロジェクトは割とうまく回ります。

ただ、意外とこれだけのことをずっと続けるのが難しいのです。

タスクの期限を入れなかったり、完了定義が曖昧だったりというのはよくあります。

期限が不明確でいつまで経っても終わらなかったり、完了の定義が曖昧で、終わったと思っても、実際には終わってなかったりするので、タスクの進捗確認はさらっと終わりがちです。

管理者はタスクが確実に終わるまで、状況を正確に把握し、きっちり管理することが求められます。

KPTでも毎週全員が参加しなかったり、課題をうまくブレークダウンできずに、本質的な課題に辿り着けず、適切な解決手段を打ち出せなかったりということもよくあります。

ファシリテーションをしながら、課題洗い出し方、解決までの思考などの教育も合わせて行っていくのがポイントです。

個の力をチームの力に変えるべく、こういった仕組みを活用しながら、仕組みがうまく回っていることを日々チェックしてマネジメントしてます。

まとめ

マネージャーは、偉い人のポジションという位置付けでは決してありません。

一方で、業務知識に加えて、コミュニケーション能力も求められる難易度の高い仕事ではあります。

性格の合わない人、経歴もバックグラウンドも違う人、カルチャーの違う人など様々な人とお付き合いしていかなければなりません。

マネージャーという役割をしっかり理解し、マネージャに求められるスキルを身につけ、メンバーのパフォーマンスを最大化させるために尽力するのが、プロフェッショナルなマネジメントだと思います。

これからのフォトシンスは、大幅に人を増員していきますが、それに耐えうる組織づくりもしっかりしていきます。 スケールしていく組織の中で、強いチームでの働き方を体感したい方は、是非エントリーしてください。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com