Android

2017年12月27日

安価な NFC タグで秘密情報を安全に携行する試み

はてなブックマークに登録

はじめに

最近 NFC まわりの調査と実験を行っています。切り口が多く奥の深い技術ですが、NFC タグ製品について調べている過程で現在もっとも広く出回っている NXP Semiconductors 製の「NTAG21x」シリーズ (ISO/IEC 14443 Type A: MIFARE Ultralight に実装されている興味深いアクセス制御機構を知りました。

NFC タグの一般的な「手軽さ」とは裏腹に、扱いを誤るとタグがあっさり使用不能になるリスクと背中合わせであるためか今のところこの機構に言及した記事やアプリケーションはあまり見かけません。しかし、仕様を理解した上で適切に取り回せばこの安価なタグ製品の使途を大きく拡げることができるでしょう。

  • 媒体が小さく軽薄で嵩張らない
  • 非接触型であるため露出の必要がなく忍ばせやすい
  • 緊急時には簡単に破壊・破棄できる

手元ではこういった物理的な特長にも注目し、NTAG21x を秘密情報の格納に利用するアイディアを形にしてみました。データ圧縮と複数タグへの分割格納にも対応しています。今回はこの NTAG21x のアクセス制御機構に関する情報の整理を行い、試作した Android アプリケーションの内容を紹介します。

ちなみに手元では実験用に NTAG213 を計 110 枚、NTAG216 を計 25 枚調達しました。どちらもまず Amazon で少量を購入し様子見を経て後顧の憂いなく使い潰せるようにより安く入手できる Aliexpress で買い足しを行いました。参考に購入元と購入時の価格を掲載します。(個人的には現時点で Aliexpress を利用する際には万一の事故に備えての措置をとっています)

NTAG213(ユーザメモリ容量 144 バイト)

NTAG216(ユーザメモリ容量 888 バイト)

なお、本記事においては NTAG メモリ内容の参照に NXP 純正の次のアプリケーションを使用しています。

1. NTAG21x のアクセス制御機構とネイティブコマンド

データシート

アクセス制御機構の概要と簡単なデモ

  • NTAG21x メモリ上の任意のページ(1 ページ = 4 バイト)以降へのアクセス要求に対し当該タグをパスワードによって保護できる
  • パスワードによる保護は Write 要求のみならず Read 要求に対しても設定可能
  • パスワード認証連続試行回数の上限値を設定することが可能であり、この回数を超過すると当該タグの認証機能は永久に封鎖されパスワード正否にかかわらず認証通過不能となる

    デモ 1: 試作アプリから NTAG213 にデータの書き込みを行い全ページの読み書きをパスワードで保護する (動画 1分39秒)

    デモ 2: 上記デモ 1 でのパスワード設定時に併せて設定した認証試行上限回数を超過する操作を行った結果タグが自爆する様子 (動画 1分2秒)

アクセス制御機構の詳細

  • データシート中の記事
    • "1. General description"
    • "8.5 Memory organization"
    • "8.6 NFC counter function"
    • "8.8 Password verification protection"
  • 要点のまとめ
    • NTAG213 の EEPROM メモリの総ページ数は 45(0h〜2Ch)。NTAG215 は 135 ページ, NTAG216 は 231 ページであり、いずれも末尾の 4 ページが Configuration Pages である。下図の NTAG213 のメモリレイアウトを参照のこと。なお 1 ページ = 4 バイト
    • Configuration 第 1 ページの第 4 バイト「AUTH0」にはパスワード保護対象とする開始ページのアドレスを指定する。このフィールドに実在ページの範囲を超えるアドレスが格納された状態ではパスワード保護は無効。出荷時のデフォルトは 0xFF
    • Configuration 第 2 ページの第 1 バイト「ACCESS」の PROT ビットが 0 ならパスワード保護の対象は Write 要求のみ。同ビットが 1 なら Read, Write 要求の両方が対象となる
    • パスワード(PWD)は 4バイト固定。Configuration 第 3 ページへ格納。出荷時の PWD のデフォルトは 0xFFFFFFFF
      • PWD は可読文字列でなくても許容されるためバリエーションは 4 バイトで 256^4 であり用途によっては意図的に非 ASCII データを適用する選択もあり得るだろう
      • 「ACCESS」の AUTHLIM にはブルートフォース攻撃対策用にパスワード認証要求(PWD_AUTH:後出)試行回数上限を設定可能。認証連続失敗回数がこの値を超えると以降の PWD_AUTH はパスワードの正否にかかわらず永久に無効となる。試行可能回数の範囲で PWD_AUTH が成功すると失敗回数を保持する内部カウンタはリセットされる。AUTHLIM の初期値は当該機能の無効を示す 000b
    • Configuration 第 4 ページの先頭 16 ビットの Password Acknowledge(PACK)はパスワード認証通過時の肯定応答値。デフォルトは 0x0000
    • パスワード保護が有効なメモリ領域の読み書きはパスワード認証を通過し PACK を得た後に可能となる
    • PWD および PACK の読み出しは不可能。両フィールドへの READ 要求に対し NTAG は常に 0x00 を返す
    • NTAG21x のパスワード保護機構は不正なメモリアクセスの防止に特化したものでありデータ暗号化等はアプリケーション側の要件
    • Configuration Pages を永続的にロックする CFGLCK 機構にも要注意
  • フィールドの整理
    (※データシートから引用した図・表を説明のために再構成しています)


  • (参考)NTAG213 初期状態のメモリダンプ:ページ 29h 以降が Configuration Pages
    ※ NXP 提供の Android アプリ TagInfo での表示
     

関連する NTAG コマンド

上記アクセス制御機構を利用する上で最低限必要な NTAG21x ネイティブコマンドおよび関連情報を示す。

  • データシート中の記事
    • "9. Command overview"
  • 要点のまとめ
    • NTAG コマンドに対する最短のレスポンスは 4ビットの ACK or NACK である
      • Ah: Acknowledge (ACK)
      • 0h: NAK for invalid argument (i.e. invalid page address)
      • 1h: NAK for parity or CRC error
      • 4h: NAK for invalid authentication counter overflow
      • 5h: NAK for EEPROM write error
    • コマンドに付与する CRC 値は ISO/IEC 14443 の規約に準拠のこと (実装例

    • GET_VERSION コマンド
      NTAG 製品のバージョン,ストレージサイズ等を得る。Configuration Pages のアドレス取得に必要
      • リクエスト
        • コマンド 1 バイト(60h) + CRC 2 バイト
      • レスポンス
        • OK: データ 8 バイト+ CRC 2 バイト
        • NG: NAK 各値
      NTAG 21x GET_VERSION レスポンスデータの内容
      
      Byte no. Description       NTAG213 NTAG215 NTAG216  Interpretation
            0  fixed Header          00h     00h     00h
            1  vendor ID             04h     04h     04h  NXP Semiconductors
            2  product type          04h     04h     04h  NTAG
            3  product subtype       02h     02h     02h  50 pF
            4  major product version 01h     01h     01h  1
            5  minor product version 00h     00h     00h  V0
            6  storage size          0Fh     11h     13h  see following information
            7  protocol type         03h     03h     03h  ISO/IEC 14443-3 compliant
      

      NTAG 製品の識別方法

      storage size の上位 7ビットを符号なし整数値 n として解釈する。使用可能なユーザーメモリの合計サイズは、最下位ビットが 0 の場合には 2^n であり最下位ビットが 1 の場合には 2^n 〜 2^(n+1) である。これを識別に利用する。

      NTAG213 での GET_VERSION レスポンス実例

      HEX: 00 04 04 02 01 00 0F 03

      --> 0x0F = BIN: 00001111 --> BIN: 0000111 = 0x07 --> 2^7 = 128, 2^(7+1) = 256

      ※ 128〜256 バイトのユーザーメモリ空間を持つ製品は NTAG213(144 バイト)

      NTAG216 での GET_VERSION レスポンス実例

      HEX: 00 04 04 02 01 00 13 03

      --> 0x13 = BIN: 00010011 --> BIN: 0001001 = 0x09 --> 2^9 = 512, 2^(9+1) = 1024

      ※ 512〜1024 バイトのユーザーメモリ空間を持つ製品は NTAG216(888 バイト)

    • READ コマンド
      所定のページアドレスから始まる 4 ページ分(16 バイト)のデータを得る
      • リクエスト
        • コマンド 1 バイト(30h)+ 開始アドレス 1 バイト + CRC 2 バイト
      • レスポンス
        • OK: データ 16 バイト+ CRC 2 バイト
        • NG: NAK 各値
    • WRITE コマンド
      所定のページアドレスへ 1 ページ分(4 バイト)のデータを書き込む
      • リクエスト
        • コマンド 1バイト(A2h)+ 対象アドレス 1 バイト + データ 4 バイト + CRC 2 バイト
      • レスポンス
        • OK: ACK
        • NG: NAK 各値
    • PWD_AUTH コマンド
      パスワードで保護された領域へのアクセス要求に先行してパスワード認証を要求する。ここで肯定応答である PACK が得られれば所定のアクセスが可能となる(PACK の値はデフォルト 0x0000 。変更可)
      • リクエスト
        • コマンド 1 バイト(1Bh)+ パスワード 4 バイト + CRC 2 バイト
      • レスポンス
        • OK: PACK 2 バイト
        • NG: NAK 各値

2. Android API による NTAG21x のハンドリングについて

手順等

  • NTAG21x は ISO/IEC 14443 Type A であるため Android でアクセス制御機構を利用する際には標準の NfcA クラスを使用する。なお MIFARE Ultralight に特化した MifareUltralight クラスも存在するがここでは前掲のネイティブコマンド操作が処理の中心であるためあえて同クラスを利用する必然性はない
  • 所定のタグの近接を検知したらまず Tag クラスの getTechList メソッドにより当該タグの TagTechnology 文字列リストを取得。リストに "android.nfc.tech.NfcA" が含まれていれば NfcA クラスの get メソッドで NfcA オブジェクトを取得し transceive メソッドにより適宜 NTAG コマンドを呼び出すことで製品の識別と必要な処理を行う。ターゲットが NTAG21x と識別されない場合は後続処理をキャンセル
  • NfcA - API level 10
    public final class NfcA

    Provides access to NFC-A (ISO 14443-3A) properties and I/O operations on a Tag.

    Acquire a NfcA object using get(Tag).

    The primary NFC-A I/O operation is transceive(byte[]). Applications must implement their own protocol stack on top of transceive(byte[]).
    • get - API level 10
      NfcA get (Tag tag)

      Get an instance of NfcA for the given tag.

      Returns null if NfcA was not enumerated in getTechList(). This indicates the tag does not support NFC-A.

      Parameters
      • tag Tag: an NFC-A compatible tag
      Returns
      • NfcA NFC-A object
    • transceive - API level 10
      byte[] transceive (byte[] data)

      Get an instance of NfcA for the given tag.

      Send raw NFC-A commands to the tag and receive the response.

      Applications must not append the EoD (CRC) to the payload, it will be automatically calculated.


      Applications must only send commands that are complete bytes, for example a SENS_REQ is not possible (these are used to manage tag polling and initialization).

      Use getMaxTransceiveLength() to retrieve the maximum number of bytes that can be sent with transceive(byte[]).

      This is an I/O operation and will block until complete. It must not be called from the main application thread. A blocked call will be canceled with IOException if close() is called from another thread.

      Parameters
      • data byte: bytes to send
      Returns
      • byte[] bytes received in response
      Throws
      • IOException if there is an I/O failure, or this operation is cance

コードを書いて試す

  1. パスワードを設定する(write 要求からの保護)
    • NTAG21x 種別を確認
    • パスワードとして "0000" を設定
    • ページ 04h 以降を保護対象とする
              :
      
          if (isNfcA) {
            int ntagMaxPage = -1, ntagConfPage0;
            NfcA nfca = NfcA.get(tag);
            try {
              nfca.connect();
            } catch (IOException e) {
              Log.d(TAG, "NfcA.connect() err: " + e.toString());
              return;
            }
            // NTAG 種別取得
            try {
              byte[] res = nfca.transceive(new byte[]{
                (byte) 0x60, // GET_VERSION
              });
              Log.d(TAG, "GET_VERSION reslen=" + res.length + " res=" + bytesToHexString(res));
              if (res.length == 8) {
                if (res[0] == 0x00 && res[1] == 0x04 &&
                    res[2] == 0x04 && res[3] == 0x02) {
                  byte ntagStorageSize = res[6];
                  if (ntagStorageSize == 0x0F) {
                    ntagMaxPage = 45; // 0x2D = NTAG213
                  } else if (ntagStorageSize == 0x11) {
                    ntagMaxPage = 135; // 0x87 = NTAG215
                  } else if (ntagStorageSize == 0x13) {
                    ntagMaxPage = 231; // 0xE7 = NTAG216
                  }
                }
              }
            } catch (IOException e) {
              Log.d(TAG, "GET_VERSION err:" + e.toString());
            }
            if (ntagMaxPage == -1) {
              Log.d(TAG, "is not NTAG21x");
              if (nfca.isConnected()) {
                try {
                  nfca.close();
                } catch (IOException e) {
                  Log.d(TAG, "NfcA.close() err: " + e.toString());
                }
              }
              return;
            }
            // Congiguration Pages 開始位置
            ntagConfPage0 = ntagMaxPage - 4;
      
            try {
              // パスワードを設定
              int page = ntagConfPage0 + 2; // config page 2
              byte[] res = nfca.transceive(
                  new byte[]{
                      (byte) 0xA2, // WRITE
                      (byte) page,
                      '0', '0', '0', '0'
                  }
              );
              Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
      
              // 保護開始ページを設定
              page = ntagConfPage0; // config page 0
              res = nfca.transceive(
                  new byte[]{
                      (byte) 0xA2, // WRITE
                      (byte) page,
                      (byte) 0x04,
                      (byte) 0x00,
                      (byte) 0x00,
                      (byte) 0x04 // 保護開始ページ
                  }
              );
              Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
            } catch (IOException e) {
              Log.d(TAG, "WRITE err:" + e.toString());
            }
      
            if (nfca.isConnected()) {
              try {
                nfca.close();
              } catch (IOException e) {
                e.printStackTrace();
              }
            }
          }
              :
      
    • 上のコードにより NTAG213 のページ 04h 以降への書き込みにパスワード認証通過が必須となった状況
      AUTH0 に保護開始ページ指定
      PROT ビットはデフォルトの 0 のまま
         
  2. パスワードを設定する(read / write 要求からの保護)

    NTAG21x では write 要求のみならず read 要求に対してもパスワードによる保護が可能。ただしパスワードは両者共通

    • NTAG21x 種別を確認
    • パスワードとして "0000" を設定
    • ページ 04h 以降を保護対象とする
    • PROT ビットを立て read 要求からの保護も有効に
              :
      
          if (isNfcA) {
            int ntagMaxPage = -1, ntagConfPage0;
            NfcA nfca = NfcA.get(tag);
            try {
              nfca.connect();
            } catch (IOException e) {
              Log.d(TAG, "NfcA.connect() err: " + e.toString());
              return;
            }
            // NTAG 種別取得
            try {
              byte[] res = nfca.transceive(new byte[]{
                (byte) 0x60, // GET_VERSION
              });
              Log.d(TAG, "GET_VERSION reslen=" + res.length + " res=" + bytesToHexString(res));
              if (res.length == 8) {
                if (res[0] == 0x00 && res[1] == 0x04 &&
                    res[2] == 0x04 && res[3] == 0x02) {
                  byte ntagStorageSize = res[6];
                  if (ntagStorageSize == 0x0F) {
                    ntagMaxPage = 45; // 0x2D = NTAG213
                  } else if (ntagStorageSize == 0x11) {
                    ntagMaxPage = 135; // 0x87 = NTAG215
                  } else if (ntagStorageSize == 0x13) {
                    ntagMaxPage = 231; // 0xE7 = NTAG216
                  }
                }
              }
            } catch (IOException e) {
              Log.d(TAG, "GET_VERSION err:" + e.toString());
            }
            if (ntagMaxPage == -1) {
              Log.d(TAG, "is not NTAG21x");
              if (nfca.isConnected()) {
                try {
                  nfca.close();
                } catch (IOException e) {
                  Log.d(TAG, "NfcA.close() err: " + e.toString());
                }
              }
              return;
            }
            // Congiguration Pages 開始位置
            ntagConfPage0 = ntagMaxPage - 4;
      
            try {
              // パスワードを設定
              int page = ntagConfPage0 + 2; // config page 2
              byte[] res = nfca.transceive(
                  new byte[]{
                      (byte) 0xA2, // WRITE
                      (byte) page,
                      '0', '0', '0', '0'
                  }
              );
              Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
      
              // 保護開始ページを設定
              page = ntagConfPage0; // config page 0
              res = nfca.transceive(
                  new byte[]{
                      (byte) 0xA2, // WRITE
                      (byte) page,
                      (byte) 0x04,
                      (byte) 0x00,
                      (byte) 0x00,
                      (byte) 0x04 // 保護開始ページ
                  }
              );
              Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
      
              // read 要求からの保護も有効に
              page = ntagConfPage0 + 1; // config page 1
              res = nfca.transceive(
                  new byte[]{
                      (byte) 0xA2, // WRITE
                      (byte) page,
                      (byte) 0x80, // read,write protect
                      (byte) 0x05,
                      (byte) 0x00,
                      (byte) 0x00
                  }
              );
              Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
            } catch (IOException e) {
              Log.d(TAG, "WRITE err:" + e.toString());
            }
      
            if (nfca.isConnected()) {
              try {
                nfca.close();
              } catch (IOException e) {
                e.printStackTrace();
              }
            }
          }
              :
      
    • 上のコードにより NTAG213 のページ 04h 以降の読み書きにパスワード認証通過が必須となった状況
         
  3. パスワードを解除する
    • NTAG21x 種別を確認
    • PWD_AUTH コマンドでパスワード "0000" を提示し認証要求
    • AUTH0 に有効範囲外のページ番号 FFh を書き込むことでパスワード保護を無効化する
              :
      
          if (isNfcA) {
            int ntagMaxPage = -1, ntagConfPage0;
            NfcA nfca = NfcA.get(tag);
            try {
              nfca.connect();
            } catch (IOException e) {
              Log.d(TAG, "NfcA.connect() err: " + e.toString());
              return;
            }
            // NTAG 種別取得
            try {
              byte[] res = nfca.transceive(new byte[]{
                (byte) 0x60, // GET_VERSION
              });
              Log.d(TAG, "GET_VERSION reslen=" + res.length + " res=" + bytesToHexString(res));
              if (res.length == 8) {
                if (res[0] == 0x00 && res[1] == 0x04 &&
                    res[2] == 0x04 && res[3] == 0x02) {
                  byte ntagStorageSize = res[6];
                  if (ntagStorageSize == 0x0F) {
                    ntagMaxPage = 45; // 0x2D = NTAG213
                  } else if (ntagStorageSize == 0x11) {
                    ntagMaxPage = 135; // 0x87 = NTAG215
                  } else if (ntagStorageSize == 0x13) {
                    ntagMaxPage = 231; // 0xE7 = NTAG216
                  }
                }
              }
            } catch (IOException e) {
              Log.d(TAG, "GET_VERSION err:" + e.toString());
            }
            if (ntagMaxPage == -1) {
              Log.d(TAG, "is not NTAG21x");
              if (nfca.isConnected()) {
                try {
                  nfca.close();
                } catch (IOException e) {
                  Log.d(TAG, "NfcA.close() err: " + e.toString());
                }
              }
              return;
            }
            // Congiguration Pages 開始位置
            ntagConfPage0 = ntagMaxPage - 4;
      
            // パスワード認証
            // 通過すれば close するまで権限が持続
            boolean authOk = false;
            try {
              byte[] res = nfca.transceive(
                  new byte[]{
                      (byte) 0x1b, // PWD_AUTH
                      '0', '0', '0', '0'
                  }
              );
              Log.d(TAG, "PWD_AUTH reslen=" + res.length + " res=" + bytesToHexString(res));
              if (res.length == 2) {
                authOk = true;
              }
            } catch (IOException e) {
              Log.d(TAG, "PWD_AUTH err:" + e.toString());
            }
      
            if (authOk) {
              // パスワード保護を解除
              try {
                int page = ntagConfPage0; // config page 0
                byte[] res = nfca.transceive(
                    new byte[]{
                        (byte) 0xA2, // WRITE
                        (byte) page,
                        (byte) 0x04,
                        (byte) 0x00,
                        (byte) 0x00,
                        (byte) 0xFF // 有効範囲超のページ指定で保護は無効に
                    }
                );
                Log.d(TAG, "WRITE p" + page + " reslen=" + res.length + " res=" + bytesToHexString(res));
              } catch (IOException e) {
                Log.d(TAG, "PWD WRITE err:" + e.toString());
              }
            }
      
            if (nfca.isConnected()) {
              try {
                nfca.close();
              } catch (IOException e) {
                Log.d(TAG, "NfcA.close() err: " + e.toString());
              }
            }
          }
              :
      
    • 上のコードにより前掲 2.のコードによるパスワード保護をこのコードで解除した状況
      ここでは PWD 自体には手をつけておらず PROT ビットも 1 のままだが、AUTH0 = FFh につきユーザ領域を自由に読み書き可能な状態に戻っている
         

3. 試作した Android アプリについて

NTAG で秘密情報を扱うという考え方

ここまで見てきたように NTAG21x はパスワード設定により Write 要求のみならず Read 要求を弾くことも可能です。これは興味深い機能ですが、両者のパスワードが共通であるためたとえば次のような取り回しはできません。

  • 部外者: タグデータへのアクセス不可
  • 利用者: Read パスワードによりタグデータの読み出しが可能
  • 管理者: 読み出しに加え Write パスワードでタグデータの書き換えが可能

このように複数のロールでタグを共用したい場合ではなく、所定のタグを独占的・排他的に扱いたい場合にこそこの機能は有用でしょう。その典型的な使用例として秘密情報の格納を想起しました。以下の発想によるものです。

  • デバイスのストレージも公共のネットワークも使用しないため情報漏洩のリスクが極めて小さい
  • 薄くて小さく軟弱な媒体であるためマスターデータの保存には不向きだが、逆にデータのコピーを携行する目的には好適であり、その状況下では簡単に媒体を破壊・破棄できることが利点にもなる
  • NFC の特性上表面を覆っても読み書きができるため手近なものに忍ばせることも容易
  • パスワードで対 Read 保護を設定しておけば他者の手に渡っても一般的なリーダーやアプリではデータを読み出せない。仮に NTAG パスワード認証を試みられたとしても事前に上限回数を設定しておけば試行の過程で当該タグは永久に封鎖される
  • 記録容量の少なさはデータ圧縮と複数タグへの分割格納で補うことが可能。嵩張らないため複数枚に渡っても携行性への影響は微小

なお、こういった使途においては NFC の世界で「データ交換」を行う上での便宜としての NDEF 形式を使用する必然性はありません。ユーザメモリ全体を任意の形式で効率よく取り回せばよいでしょう。

試作中の情報収集時に、秘密情報を紙媒体で管理する「パスワードノート」という製品があることを知りました。

ストレージやネットワークを使用しない点が共通しており興味を持ちました。たしかに「手書き」には他の方法では決して及ばない様々な柔軟性があります。これがヒントとなって NTAG を手帳へ貼付して使うアイディアに至りました。ふたつの文化の利点を活用できる良い組み合わせだと思います。

実装内容

試作の仕様として以下の内容を想定しました。

  1. NTAG213, NTAG216 を対象とする
  2. NDEF は使用せず最小限のデータヘッダに続けてユーザメモリ一杯までデータを格納する
  3. テキスト形式のデータであることを前提とする。容量節約のためマルチバイト文字のエンコーディングには Unicode ではなく Shift-JIS を使用する
  4. データは gzip 形式で圧縮する
  5. データが一枚のタグに収まらない場合は複数のタグへ分割格納する。ただし、データを分割して格納したタグはそれぞれ単独での読み出しも可能とする(全タグの順次読み出しをデータ復元の前提とせず、全タグが揃わなくても部分データを柔軟に取得できるようにする)
  6. Read/Write 要求に対しメモリの全ページをパスワード保護対象とする
  7. NTAG の 4 バイトのパスワード領域へ 4 バイトの ASCII キャラクタコードを格納するのではなく指定されたパスワード文字列の CRC32 値を格納する (PWD のバリエーションは 256^4 となる)
  8. 上記の措置のため他のツールではここで設定したパスワード保護を解除不能につき解除機能を独自に実装する必要がある
  9. 総当たり攻撃への対処としてパスワード認証の試行回数に上限値(AUTHLIM)を設定することにより試行回数超過の場合には NTAG の自爆機構(PWD_AUTH 要求に対する無条件拒否)を発動させる
  10. 上記にも関連し、ここではマスターデータの保存を目的とするのではなく、コピーデータの便宜的な保持を目的とすることを前提とする

NTAG ユーザメモリの使いかた

 ========= ユーザメモリ領域全体の構成 =========

 1. page 04h - 05h に固有の管理情報を格納する
 2. page 06h 以降に gzip データを格納する

 ========= 1. 管理情報について =========

 [page 04h]
    0    1    2    3
  +----+----+----+----+
  |'t' |'t' | -- | -- |
  +----+----+----+----+

  第 1, 2 バイト
    識別子 "tt"

  第 3, 4 バイト
    予備
    ※当初、データを分割格納したタグセットの識別用領域と
      することを想定したが費用対効果に乏しいと判断し中止

 [page 05h]
    0    1    2    3
  +----+----+----+----+
  | NN | -- | NN | NN |
  +----+----+----+----+
  
  第 1 バイト
   最上位ビット:後続タグの有無  0=後続なし 1=後続あり
   下位 4 ビット:タグ連番(0h - Fh)

  第 2 バイト
   予備

  第 3, 4 バイト
   ページ 06h 以降に格納ずみの gzip データサイズ
   short ビッグエンディアン

 ========= 2. gzip データについて =========

  - テキストデータの gzip 圧縮はオンメモリで行う

  - タグへ書き込む際には gzip データの半固定ヘッダ
    10バイト(*)を除去し、読み出しの際には当該ヘッダを
    補填した上で unpack する
    (*) http://www.onicos.com/staff/iz/formats/gzip.html

  - データを複数のタグへ分割格納する場合は上記の管理情報で
    連番管理を行うが、事後に各タグから単独で部分データを
    読み出すことも可能とするために、タグへ書き込むのは
    「元データ全体を圧縮した gzip データの一部分」ではなく
    「元データを適切な位置で分割して圧縮した gzip データ」とする

ソースコード

※本記事の冒頭でも触れたように NTAG21x のアクセス制御機構の扱いには十分な注意が必要です。 試作のソースコード公開はあくまでも技術情報の紹介を目的とするものでありプログラム実行時の動作は保証しません。これを使用して何らかの損害が発生したとしても当方は一切の責任を負いません。

動作の様子

以下のデモ動画ではテストデータとして次の 4,978 バイトの英単語リストを使用。このデータを圧縮して 3 枚の NTAG216 に分割格納しています。

START, ability, abroad,
accept, access, accident,
according, account, action,
activity, actually, add,
addition, additional, address,
adult, advance, advanced,
advantage, advice, advise,
age, agency, agent,
agree, ahead, air,
airline, allow, amount,
angry, announce, announcement,
anxious, appear, appearance,
application, apply, approach,
area, arrange, arrangement,
arrival, arrive, article,
attack, attend, attention,
available, average, avoid,
aware, balance, balanced,
bar, base, basic,
bear, beat, beauty,
benefit, bill, bit,
block, blood, board,
borrow, boss, branch,
break, broad, broadcast,
brush, budget, burn,
business, busy, cab,
cable, call, cancel,
capital, care, careful,
case, cash, catalog,
catch, cause, century,
certain, certainly, chance,
change, charge, chart,
cheap, check, chemical,
choose, citizen, claim,
clear, clerk, close,
clothes, collect, collection,
comfortable, common, communication,
company, compare, complain,
complete, concern, condition,
contact, contain, content,
continue, contract, control,
convenient, conversation, copy,
corner, correct, cost,
count, couple, course,
court, cover, crash,
create, credit, cross,
crowd, crowded, customer,
daily, damage, data,
date, deal, decide,
decision, defence, degree,
delay, deliver, demand,
department, depend, deposit,
describe, design, destroy,
detail, develop, dial,
diet, difference, difficulty,
direct, direction, directly,
director, discount, discuss,
disease, disk, display,
distance, district, document,
double, doubt, downtown,
dress, drive, drop,
drug, due, earn,
earthquake, economy, edge,
education, effect, effective,
effort, electricity, employee,
empty, encourage, energy,
enjoy, enter, entrance,
equipment, event, examination,
example, excellent, except,
exciting, excuse, exercise,
exit, expect, expense,
expensive, experience, expert,
explain, express, extra,
face, fail, fair,
fall, fan, fare,
fashion, fault, favor,
favorite, feature, fee,
fence, figure, file,
fill, film, final,
fine, fire, firm,
fit, fix, flag,
flat, flood, floor,
flow, follow, following,
force, foreign, form,
former, forward, found,
foundation, free, freeze,
front, fund, furniture,
further, future, gain,
garage, gas, gather,
general, global, goal,
government, grade, graduagte,
grand, ground, guard,
guess, guide, handle,
hang, happen, hardly,
head, heart, heat,
highly, hire, hold,
host, human, hurt,
illness, image, immediately,
improve, include, income,
increase, individual, industry,
inform, information, insect,
instead, instruction, insurance,
intend, interest, international,
interview, introduce, invitation,
invite, issue, item,
job, join, judge,
knowledge, lack, land,
language, law, lawyer,
lay, lead, lend,
length, level, license,
lie, lift, likely,
limit, limitation, line,
list, load, loan,
local, location, lock,
lose, loss, luck,
lucky, luggage, mail,
main, major, majority,
manage, management, manager,
mark, market, master,
match, material, matter,
meal, means, measure,
media, medicine, mention,
message, method, mind,
minute, miss, mix,
model, modern, moment,
monthly, movemet, nation,
national, nearly, necessary,
notice, offer, office,
officer, official, operate,
operation, operator, opinion,
order, organization, own,
pack, package, pain,
part, party, pass,
passenger, patient, pay,
payment, per, performance,
performance, period, permit,
personal, pick, plain,
plan, plant, plate,
pleasure, plenty, point,
pole, policy, popular,
position, possible, post,
powerful, practice, prefer,
prepare, present, press,
prevent, price, print,
private, probably, problem,
process, produce, product,
production, professional, profit,
program, progress, project,
promise, property, protect,
protection, proud, provide,
public, publish, purpose,
puzzle, quality, quarter,
race, raise, range,
rapid, rate, reach,
ready, realize, reason,
receive, recent, recently,
recommend, record, reduce,
refer, regard, regarding,
regular, relationship, remember,
remove, rent, repair,
report, request, require,
research, reserve, responsible,
rest, result, return,
rise, room, route,
row, run, rush,
safe, safety, sail,
salary, sale, save,
schedule, seat, section,
security, sense, separate,
separately, serious, serve,
service, shake, shape,
share, ship, shock,
short, show, sign,
signal, single, situation,
skill, skin, slip,
social, solve, sort,
sound, spare, spend,
spot, spread, staff,
stage, stair, stand,
standard, state, statement,
step, stock, store,
storm, stretch, strike,
study, subject, submit,
succeed, success, suffer,
suggest, suit, supply,
support, suppose, sure,
surprise, survey, swing,
system, taste, tax,
temperature, terible, term,
tie, tight, tip,
tired, tool, total,
touch, tour, trade,
traffic, training, trip,
trouble, turn, type,
usual, valuable, value,
view, vote, want,
war, warn, way,
wear, weather, win,
wind, wire, wise,
wonder, worth, worthy,
xenophobia, yard, yarn,
yawn, yearn, yell,
yellow, yesterday, yet,
yield, yolk, young,
youth, zeal, zenith,
zigzag, zinc, zoom,
END

動画:5分1秒

※パスワード認証試行上限回数を超過した状況の動画はこちら


(tanabe)
klab_gijutsu2 at 17:00|この記事のURLComments(0)
2017年06月15日

音を利用する 3 〜Android デバイスでの音波通信:実装編〜

はてなブックマークに登録

この記事の内容

前回の記事では Android デバイス間で音波通信を行うための準備として次のみっつの要素に目を向けました。

今回はこれらを素材として音声信号でビットデータを送受信してみます。四段階に分け順次機能を加えながら試作を行います。

2017-11-29 追記:
本記事に掲載のコードは開発時に Android 5.x 環境を使用しており、Android 6.0 以降では android.permission.RECORD_AUDIO について実行時パーミッション取得が必要です。
この件への対応例として sonic01 の MainActivity.java に手を加えました。 (diff
他のプロジェクトについても適宜加味して下さい。

1. ビットデータを音声信号で送受信する 1

想定した内容

まず最初の試作に際し次の内容を想定しました。

  • 送信側は送信専用、受信側は受信専用とする
  • 送信側は任意の文字列を所定の規則に添ってサイン波信号に変換しオーディオ出力する
  • 受信側は集音中に所定の規則に添った音声信号を検知するとそこから文字列情報を復元し画面に表示する
  • 開発上の便宜のために可聴音を使用する

以下についてはとりあえず後まわしです。

  • 伝送効率
  • 誤りへの対策

周波数変調(FSK)について

所定のデータを音声信号に変換するために周波数変調方式を利用することにしました。次の考え方です。

  • ふたつの周波数 A, B に 0 と 1 を割り当てる
  • 元データのビット並びを上記 A, B に置き換えそれらを所定の時間間隔で切り替える

  • FSK - 「通信用語の基礎知識」より
    正弦波に対してディジタル信号で変調を行なういわゆるディジタル変調の方式の一つで、ディジタル値を正弦波の周波数に対応させて伝送する方式のこと。具体的にはディジタル値の1/0に対応させて2つの周波数を決め、入力されるディジタル信号に応じてそれぞれの周波数の正弦波を交互に送出することで実現させる。

試作

処理のイメージ

ざっくり以下の要領で実装を試みました。

  • 周波数変調においては 8000Hz を 0, 10000Hz を 1 として扱い 1 ビットあたりの発振持続時間を 100ミリ秒とする (よって伝送速度は 10bps)

  • 信号出力に際しては単一バッファ上に終始の完結したデータを構築するのではなく生成済みの 8000Hz, 10000Hz 信号を再生バッファに順次直接書き込む
  • 今回は参照していないが末尾に終端の符丁として 12000Hz の信号を 1秒間付与する
  • 左:送信側 右:受信側

動作の様子

動画: 1分30秒  ※音量注意!

ソースコード

送信側: sonic05

  • MainActivity.java
    
    /**
     *
     * sonic05
     *
     * 文字列をサイン波信号に置き換えて出力する
     * Lo -> 8000Hz, Hi -> 10000Hz
     * 対向の受信プログラムは sonic06
     *
     */
    
    package jp.klab.sonic05;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.EditText;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_100MSEC = SAMPLE_RATE/10;
    
      private static final int FREQ_LOW  = 8000;
      private static final int FREQ_HI  = 10000;
      private static final int FREQ_END = 12000;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private static final byte [] BITS = new byte [] {
          (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10,
          (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01};
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private short mPlayBuf1[];
      private short mPlayBuf2[];
      private short mPlayBuf3[];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < ELMS_1SEC; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mPlayBuf1 = new short[ELMS_1SEC];
        mPlayBuf2 = new short[ELMS_1SEC];
        mPlayBuf3 = new short[ELMS_1SEC];
        createSineWave(mPlayBuf1, FREQ_LOW, AMP, true); // Low
        createSineWave(mPlayBuf2, FREQ_HI,  AMP, true); // Hi
        createSineWave(mPlayBuf3, FREQ_END, AMP, true); // END
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        valueToWave((byte) 0x20); // ダミー
        mAudioTrack.write(mPlayBuf3, 0, ELMS_1SEC); // 終端
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(byte val) {
        for (int i = 0; i < BITS.length; i++) {
          // ビットごとに Hi, Low を出力
          mAudioTrack.write(((val & BITS[i]) != 0) ? mPlayBuf2 : mPlayBuf1, 0, ELMS_100MSEC);
        }
      }
    }
    
    

受信側: sonic06

  • MainActivity.java
    
    /**
     *
     * sonic06
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * Lo -> 8000Hz, Hi -> 10000Hz
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic05
     *
     */
    
    package jp.klab.sonic06;
    
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_LOW =  8000;
      private static final int FREQ_HI  = 10000;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 130;
    
      private static final byte [] BITS = new byte [] {
          (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10,
          (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01};
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[SAMPLE_RATE/10]; // 100msec
        mFFTSize = mBufferSizeInShort;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        int bitCount = 0;
        byte val = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = bitCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = mTestBuf.length - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= mTestBuf.length) {
            byte ret = doFFT(mTestBuf);
            //Log.d(TAG, "ret=" + ret);
            if (ret == -1) { // FREQ_LOW, FREQ_HI 以外の周波数の場合
              dataCount = bitCount = 0;
              continue;
            } else {
              // バイトデータを順次構成
              if (bitCount == 0) {
                val = 0;
              }
              val |= (ret == 1) ? BITS[bitCount] : 0;
              if (bitCount < 7) {
                bitCount++;
              } else { // 1バイト分完了
                bitCount = 0;
                Message msg = new Message();
                msg.what = MSG_DATA_RECV;
                msg.arg1 = (int) val;
                mHandler.sendMessage(msg);
              }
            }
            dataCount = 0;
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 録音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private byte doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        return (byte)((freq == FREQ_LOW) ? 0 : (freq == FREQ_HI) ? 1 : -1);
      }
    }
    
    

周波数切り替え箇所の波形を見る

信号中の Lo(8000Hz)から Hi(10000Hz)、Hi から Lo への切り替え部分の波形の確認を行いました。

Lo, Hi ともに 10 で割り切れる周波数なので、先頭から 100ミリ秒後の位置での運動円上の Y 座標は 1秒後の位置と等しく 0 であり、そこに同じく Y=0 始まりの別の周期のサイン波を接合しても位相の不連続は発生しないはずです。 (ただし周期の違いが継ぎ目前後のカーブの形状の違いとして表れるため波形として完璧ではないでしょう)

以下は送信側アプリからの信号出力を PC に接続したマイクから取り込んだ Audacity のスクリーンショットです。

Lo -> Hi 部分のズーム

後続の Hi -> Lo 部分を同様にズーム

2. ビットデータを音声信号で送受信する 2

上の最初の試作ではまず音によるデータの送受信自体が可能であることを確認しましたが、動作が不安定で何より通信の遅さが目につきます。この点について考えてみます。

伝送効率改善の考え方

効率を改善するために伝達する情報の内容に手を加えることにしました。1バイト分のキャラクタデータをビットごとに 0,1 を表わすふたつの周波数の波の羅列で表現するのではなく、256種類の周波数を用いて直接 0x00 〜 0xFF を表現する形にすれば理論上伝送効率が 8倍向上するはずです。これを具体化してみることにします。

試作

処理のイメージ

以下の要領で実装を行いました。

  • 送信側はバイト値 0x00 〜 0xFF を 400Hz 〜 5500Hz の 20Hz 刻みの 256 パターンの周波数から成る音声信号に変調する
  • 受信側は「(検知したピーク周波数 - 400)/ 20」の平易な計算で元のバイト値へ復号する
  • アプリの UI は最初の試作と同じ

動作の様子

動画: 1分  ※音量注意!

ソースコード

送信側: sonic07     GitHub

  • MainActivity.java
    /**
     * sonic07
     *
     * 文字列をサイン波信号に置き換えて出力する
     * 伝送効率改善版
     * 対向の受信プログラムは sonic08
     *
     */
    
    package jp.klab.sonic07;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.EditText;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
      private static final int FREQ_BASE = 400;
      private static final int FREQ_STEP = 20;
      private static final int FREQ_KEY = 300;
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_100MSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private short mPlayBuf[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        // 先頭・終端の目印用信号データ
        createSineWave(mPlayBuf, FREQ_KEY, AMP, true);
    
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP*i), AMP, true);
        }
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 開始
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 終端
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(byte val) {
        mAudioTrack.write(mSignals[val], 0, ELMS_100MSEC);
      }
    }
    
    
    

受信側: sonic08     GitHub

  • MainActivity.java
    
    /**
     * sonic08
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * 伝送効率改善版
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic07
     *
     */
    
    package jp.klab.sonic08;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_BASE = 400;
      private static final int FREQ_STEP = 20;
      private static final int FREQ_MAX = 400 + 255 * 20;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 120;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = mTestBuf.length - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= mTestBuf.length) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                Message msg = new Message();
                msg.what = MSG_DATA_RECV;
                msg.arg1 = val;
                mHandler.sendMessage(msg);
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        //Log.d(TAG, "peak=" + freq + "Hz");
        return freq;
      }
    }
    
    

3. ビットデータを音声信号で送受信する 3

伝送効率の改善に続き、次の要素に目を向けてみます。

  • 誤り発生への対処
  • 超音波の利用

誤り発生への対処について

他の通信方法と同様に、音波による通信においても送受信の際に何らかの理由でデータに誤りが発生する可能性があります。この問題への対処について考えてみます。

まず誤りを検出可能であること、さらに、自動的にデータを訂正することができれば理想的でしょう。しかしながら、誤り訂正を実現するためには何らかの形でデータの冗長化が必須となり必然的に伝送効率とのトレードオフとなります。冗長化されたより大きなデータを授受することで自動訂正実現の可能性向上を選択すべきか?あるいは、誤り有無判定のみを可能とするスリムなデータを短時間で授受することを旨とするか?

たとえば、プライベートな環境での音波通信の応用を想定すると、エラー発生に気づいた利用者が自発的に送受信を再試行するのはごく普通の自然な流れです。この場面では訂正機能よりもすみやかに通信が行われることのほうが求められるでしょう。また、パブリックな空間でのビーコン系サービスにおいては送り手が同一の情報を所定の期間何度も繰り返し発信する(それもまた単方向通信であることに起因)ことが一般的なので受け手には情報取得に成功するまで何度もチャンスが提供されます。

このように考えると、ここでは誤り訂正のための機構は現在の話題に不可欠の要素ではなく、むしろ誤りの検出までが重要であることが見えてきます。

超音波の利用について

ここまでの試作では開発上の便宜から可聴音域の信号で通信を行ってきました。

音を利用する 1」冒頭の「はじめに」の項でも触れたように、音声信号を利用する際の音域の選定は環境や使途とのかねあいによって単に適材適所で判断されるべきはずのものですが、そのこととはあまり関係なく洋の東西を問わず「超音波」「Ultrasonic」は人気がありますね。そろそろこのあたりで従来の可聴音に加え超音波も扱ってみることにします。

試作

処理のイメージ

以下の要領で実装を行いました。

可聴音モードと超音波モード

「超音波モード」を追加。

  • 送信側は所定のバイト値 0x00 〜 0xFF を以下の周波数域で構成される音声信号に変調する
    (前項のふたつめの試作では 20Hz刻みとしたが 10Hz刻みでも性能上の支障が見られないため帯域幅を節約)
    • 可聴音モード:500Hz 〜 3050Hz(10Hz 刻み)
    • 超音波モード:14000Hz 〜 16550Hz(10Hz 刻み)

先端符丁と終端符丁

発信する音声信号の先頭と終端に目印として上図の範囲に含まれない所定の周波数の信号を 1秒間付与する。

  • 先端符丁:各モードで使用する音域の先頭周波数 − 80Hz(=420Hz,13920Hz)
  • 終端符丁:各モードで使用する音域の先頭周波数 − 100Hz(=400Hz, 13900Hz)

誤り検出

次の内容で誤り検出機構を追加。

  • 送信側は先端符丁とデータ本体の間にデータの 32ビット CRC 計算結果(java.util.zip.CRC32 使用)を 4 * 100ミリ秒間発信する
  • 受信側は先端符丁を検知すると上記 CRC 値を取得・復号して保持。終端符丁検出時に復号ずみデータ本体の CRC を計算し両者を比較することで誤りの可能性の有無を判定する
    • データ本体の受け渡しに失敗したケースのみならず CRC 値の受け渡しに失敗のケースもありえるが、現時点では CRC 値情報授受の冗長化は考えず一律に「エラー」とみなす

UI

  • 左:送信側 右:受信側
    両者とも「Ultrasonic」スイッチで超音波モードへ移行

動作の様子

動画: 1分19秒  ※音量注意!

ソースコード

送信側: sonic09     GitHub

  • MainActivity.java
    
    /**
     * sonic09
     *
     * 文字列をサイン波信号に置き換えて出力する
     * 32ビットCRC付与, 超音波モード追加
     * 対向の受信プログラムは sonic10
     *
     */
    
    package jp.klab.sonic09;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.CompoundButton;
    import android.widget.EditText;
    import android.widget.Switch;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
        Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int AMP_SMALL = 5000;
      private static final int AMP_LARGE = 28000;
    
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_UNITSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private int FREQ_STEP = 10;
      private int AMP;
      private int FREQ_BASE;
      private int FREQ_OUT;
      private int FREQ_IN;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private Switch mSwitch01;
    
      private short mSigIn[] = new short[SAMPLE_RATE];
      private short mSigOut[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][ELMS_UNITSEC];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
        setParams(false);
    
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        setParams(isChecked);
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁
        // データ本体の CRC32 を計算して発信
        CRC32 crc = new CRC32();
        crc.reset();
        crc.update(strByte, 0, strByte.length);
        long crcVal = crc.getValue();
        //Log.d(TAG, "crc=" + Long.toHexString(crcVal));
        byte crcData[] = new byte[4];
        for (int i = 0; i < 4; i++) {
          crcData[i] = (byte)(crcVal >> (24-i*8));
          valueToWave((short) (crcData[i] & 0xFF));
        }
        // データ本体
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(short val) {
        //Log.d(TAG, "val=" + val);
        mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC);
      }
    
      private void setParams(boolean useUltrasonic) {
        AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL;
        FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
        FREQ_OUT = FREQ_BASE - 100;
        FREQ_IN = FREQ_OUT + 20;
        // 先端・終端符丁の信号データを生成
        createSineWave(mSigIn, FREQ_IN, AMP, true);
        createSineWave(mSigOut, FREQ_OUT, AMP, true);
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true);
        }
    
      }
    }
    
    

受信側: sonic10     GitHub

  • MainActivity.java
    
    /**
     * sonic10
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * CRC32による誤り検出, 超音波モードへ対応
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic09
     *
     */
    
    package jp.klab.sonic10;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.graphics.Color;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.CompoundButton;
    import android.widget.Switch;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    import java.util.ArrayList;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
          Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int FREQ_STEP = 10;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 120;
      private static final int MSG_RECV_OK    = 200;
      private static final int MSG_RECV_NG    = 210;
    
      private int FREQ_BASE = FREQ_BASE_LOW;
      private int FREQ_OUT = FREQ_BASE - 100;
      private int FREQ_IN = FREQ_OUT + 20;
      private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
      private TextView mTextView03;
      private Switch mSwitch01;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
      private byte mValueCount = -1;
      private long mCrc32Val = 0;
      private String mRecvStr;
      private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>();
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
        mTextView03 = (TextView)findViewById(R.id.textView03);
        mTextView03.setTextColor(Color.RED);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
          mTextView03.setText("");
        }
        return;
      }
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        if (b == (CompoundButton)mSwitch01) {
          FREQ_BASE = (isChecked) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
          FREQ_OUT = FREQ_BASE - 100;
          FREQ_IN = FREQ_OUT + 20;
          FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            mSwitch01.setEnabled(false);
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            mSwitch01.setEnabled(true);
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            mTextView03.setText("");
            break;
          case MSG_RECV_OK:
            mTextView03.setText("OK!");
            break;
          case MSG_RECV_NG:
            mTextView03.setText("NG!");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < UNITSIZE) {//mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = UNITSIZE - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= UNITSIZE) {//mTestBuf.length) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            if (mValueCount < 0) {
              // データ終了
              if (freq == FREQ_OUT && mCrc32Val != 0) {
                byte check [] = new byte[mDataArrayList.size()];
                for (int i = 0; i < check.length; i++) {
                  check[i] = mDataArrayList.get(i);
                }
                CRC32 crc = new CRC32();
                crc.reset();
                crc.update(check, 0, check.length);
                long crcVal = crc.getValue();
                Log.d(TAG, "crc check=" +  Long.toHexString(crcVal));
                if (crcVal == mCrc32Val) {
                  mHandler.sendEmptyMessage(MSG_RECV_OK);
                } else {
                  mHandler.sendEmptyMessage(MSG_RECV_NG);
                }
                mCrc32Val = 0;
              }
            }
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                if (mValueCount > 4) {
                  mDataArrayList.add((byte)val);
                  Message msg = new Message();
                  msg.what = MSG_DATA_RECV;
                  msg.arg1 = val;
                  mHandler.sendMessage(msg);
                }
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP);
    
        if (freq == FREQ_IN) { // 先端符丁
          mValueCount = 0;
          mCrc32Val = 0;
          if (!mDataArrayList.isEmpty()) {
            mDataArrayList.clear();
          }
          return freq;
        } else if (freq == FREQ_OUT) { // 終端符丁
          mValueCount = -1;
          return freq;
        }
    
        // 先端符丁直後の 4バイトは32ビットCRC
        if (mValueCount >= 0 &&  mValueCount < 4) {
          mCrc32Val |=  (val & 0xFF);
          if (mValueCount != 3) {
            mCrc32Val <<= 8;
          } else {
            Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val));
          }
        }
        mValueCount++;
        return freq;
      }
    }
    
    

4. ビットデータを音声信号で送受信する 4

ここまでの試作では単方向の通信のみを扱ってきました。これは以下の理由によるものです。

  1. 集音・発音の両機能を混在させると自機の発した信号音による混乱が起こり得る
  2. 伝送が基本的に低速であるため機器間で相互に通信を行うなら双方の拘束時間が長くなることになり実用性を想定しにくい
  3. 双方向通信に伴いプロトコルが煩雑化することよりも単方向通信であることを活かせる用途を選ぶべき

これらはおそらく考え方として間違っていないように思いますが、一方で、二台の機器が相互に音で情報をやりとりする光景を想像すると理由のない好奇心をくすぐられます。そこで、実験の一環として最後にデバイス間の双方向通信を試すことにしました。

双方向通信を考える

双方向通信の方法として以下の内容を想定しました。

試作

処理のイメージ

以下の要領で実装を行いました。

「しりとり」を題材とする

  • 機器間で自律的にある程度意味のある応酬を行わせるために英単語のしりとりを題材とした
    ability -> yellow -> warn -> national .... の要領
  • 機器間の応酬は自動化し最初の単語を発信するために初回のみ UI を操作する形とした

集音・発音の切り替え

  • 集音状態での待機中に先端符丁の信号音を検知したらデータ到着とみなし後続処理を開始。その後終端符丁を検知したら所定の時間間隔をおいて発信を行う。発信が完了したら集音状態での待機に戻る

再送要求

  • 受信・復号したデータの内容に誤りを検出した場合、「?」文字を発信することで相手に再送を要求する

UI

  • タイトル部分をタップすると最初の単語を発信。あとはエンドレス

動作の様子

動画: 1分29秒  ※音量注意!

ソースコード

sonic11     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic11
     *
     * サイン波音声信号による半二重式での双方向通信のしくみの試作
     * 二台の機器間で「しりとり」を行う
     * FFT 処理に JTransforms ライブラリを利用
     *
     */
    
    package jp.klab.sonic11;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.graphics.Color;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.CompoundButton;
    import android.widget.Switch;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
          Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int THRESHOLD_COUNT_SILENCE = 25; // 無音区間判定用
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int AMP_SMALL = 28000;
      private static final int AMP_LARGE = 28000;
    
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_UNITSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
      private static final int FREQ_STEP = 10;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START  = 100;
      private static final int MSG_RECORD_END    = 110;
      private static final int MSG_DATA_RECV     = 130;
      private static final int MSG_DATA_SEND_START = 140;
      private static final int MSG_RECV_OK     = 200;
      private static final int MSG_RECV_NG     = 210;
    
      private int AMP;
      private int FREQ_BASE;
      private int FREQ_OUT;
      private int FREQ_IN;
      private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
      private AudioTrack mAudioTrack = null;
    
      private Button mButton01;
      private TextView mTextView01;
      private TextView mTextView02;
      private TextView mTextView03;
      private TextView mTextView04;
      private Switch mSwitch01;
    
      private short mSigIn[] = new short[SAMPLE_RATE];
      private short mSigOut[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10];
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
      private byte mValueCount = -1;
      private long mCrc32Val = 0;
      private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>();
      private int mLastFreq;
      private String mRecvWord = "";
      private String mSendWord = "";
      private boolean mRecvOK;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView01 = (TextView)findViewById(R.id.textView01);
        mTextView01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
        mTextView03 = (TextView)findViewById(R.id.textView03);
        mTextView03.setTextColor(Color.RED);
        mTextView04 = (TextView)findViewById(R.id.textView04);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf = new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
    
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
            SAMPLE_RATE,
            AudioFormat.CHANNEL_OUT_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSizeInBytes,
            AudioTrack.MODE_STREAM);
    
        setParams(false);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView01) {
          // しりとりの開始・再開
          // すでに集音中なら自分で自分の音を拾わないように
          // スレッドを一旦終了
          boolean running = mInRecording;
          if (mInRecording) {
            mStop = true;
            try {
              while (mInRecording) {
                Thread.sleep(100);
              }
            } catch (InterruptedException e) {
            }
          }
          // ランダムに選んだ英単語を音で発信
          mSendWord = words.getWord();
          doSendWord(mSendWord);
          if (running) {
            // 集音スレッド再稼働
            mInRecording = true;
            mStop = false;
            new Thread(this).start();
          }
        } else if (v == (View)mTextView02) {
          mTextView02.setText("");
          mTextView03.setText("");
        }
        return;
      }
    
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        if (b == (CompoundButton)mSwitch01) {
          setParams(isChecked);
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            mSwitch01.setEnabled(false);
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            mSwitch01.setEnabled(true);
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            if (mRecvWord.length() <= 0) {
              mTextView02.setText("RECV: ");
            }
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              mRecvWord += s;
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            mTextView03.setText("");
            break;
          case MSG_DATA_SEND_START:
            mTextView04.setText("SEND: " + mSendWord);
            break;
          case MSG_RECV_OK:
            mTextView03.setText("OK!");
            break;
          case MSG_RECV_NG:
            mTextView03.setText("NG!");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        long countSilence = 0;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声信号読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
              break;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            countSilence++;
          } else {
            countSilence = 0;
          }
          // 単語を受信ずみかつ一定期間以上静寂が続いたら送信にまわる
          if (countSilence > THRESHOLD_COUNT_SILENCE &&
              mLastFreq == FREQ_OUT && mRecvWord.length() > 0) {
            if (mRecvOK) { // 直前の受信・復号結果が正常だった
              if (!mRecvWord.equals("?")) {
                // 受信した単語のしりとり語を取得
                mSendWord = words.getWord(mRecvWord);
              }
            } else {
              // 再送要求
              mSendWord = "?";
            }
            Log.d(TAG, "mSendWord=" + mSendWord);
            // 単語を発信
            doSendWord(mSendWord);
            continue;
          }
    
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < UNITSIZE) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = UNITSIZE - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= UNITSIZE) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            mLastFreq = freq;
            if (mValueCount < 0) {
              // データ終了
              if (freq == FREQ_OUT && mCrc32Val != 0) {
                byte check [] = new byte[mDataArrayList.size()];
                for (int i = 0; i < check.length; i++) {
                  check[i] = mDataArrayList.get(i);
                }
                //Log.d(TAG, "mRecvWord=" + mRecvWord);
                CRC32 crc = new CRC32();
                crc.reset();
                crc.update(check, 0, check.length);
                long crcVal = crc.getValue();
                //Log.d(TAG, "crc check=" +  Long.toHexString(crcVal));
                mRecvOK = false;
                if (crcVal == mCrc32Val) {
                  mRecvOK = true;
                  mHandler.sendEmptyMessage(MSG_RECV_OK);
                } else {
                  mHandler.sendEmptyMessage(MSG_RECV_NG);
                }
                mCrc32Val = 0;
              }
            }
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                if (mValueCount > 4) {
                  mDataArrayList.add((byte)val);
                  Message msg = new Message();
                  msg.what = MSG_DATA_RECV;
                  msg.arg1 = val;
                  mHandler.sendMessage(msg);
                }
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mInRecording = false;
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP);
    
        if (freq == FREQ_IN) { // 先端符丁
          mValueCount = 0;
          mCrc32Val = 0;
          if (!mDataArrayList.isEmpty()) {
            mDataArrayList.clear();
          }
          mRecvWord = "";
          return freq;
        } else if (freq == FREQ_OUT) { // 終端符丁
          mValueCount = -1;
          return freq;
        }
    
        // 先端符丁直後の 4バイトは32ビットCRC
        if (mValueCount >= 0 &&  mValueCount < 4) {
          mCrc32Val |=  (val & 0xFF);
          if (mValueCount != 3) {
            mCrc32Val <<= 8;
          } else {
            Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val));
          }
        }
        mValueCount++;
        return freq;
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(short val) {
        //Log.d(TAG, "val=" + val);
        mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC);
      }
    
      private void doSendWord(String str) {
        mSendWord = str;
        mHandler.sendEmptyMessage(MSG_DATA_SEND_START);
        Log.d(TAG, "mSendWord=" + mSendWord);
        byte[] strByte = null;
        try {
          strByte = mSendWord.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁
        // データ本体の CRC32 を計算して発信
        CRC32 crc = new CRC32();
        crc.reset();
        crc.update(strByte, 0, strByte.length);
        long crcVal = crc.getValue();
        //Log.d(TAG, "crc=" + Long.toHexString(crcVal));
        byte crcData[] = new byte[4];
        for (int i = 0; i < 4; i++) {
          crcData[i] = (byte)(crcVal >> (24-i*8));
          valueToWave((short) (crcData[i] & 0xFF));
        }
        // データ本体
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁
        mAudioTrack.stop();
        mAudioTrack.flush();
        mLastFreq = -1;
        mRecvWord = "";
      }
    
      private void setParams(boolean useUltrasonic) {
        AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL;
        FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
        FREQ_OUT = FREQ_BASE - 100;
        FREQ_IN = FREQ_OUT + 20;
        // 先端・終端符丁の信号データを生成
        createSineWave(mSigIn, FREQ_IN, AMP, true);
        createSineWave(mSigOut, FREQ_OUT, AMP, true);
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true);
        }
        FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
      }
    }
    
    

(tanabe)
klab_gijutsu2 at 09:00|この記事のURLComments(0)
2017年06月02日

音を利用する 2 〜Android デバイスでの音波通信:準備編〜

はてなブックマークに登録

この記事の内容

前回の記事では音を媒体とする機器間の情報通信が近年ふたたび注目されていることに触れ複数の具体的な事例を挙げました。近距離通信手段としての音波の利用には技術的好奇心をそそられますが、それをどのように形にすればよいのかは案外ピンと来ません。そこで、Android デバイスを題材に手元で行った音波通信の実験と試作の内容をこれから何回かに分けて紹介します。今回は準備編として実装に必要な要素の整理を行います。

2017-11-29 追記:
本記事に掲載のコードは開発時に Android 5.x 環境を使用しており、Android 6.0 以降では android.permission.RECORD_AUDIO について実行時パーミッション取得が必要です。
この件への対応例として sonic01 の MainActivity.java に手を加えました。 (diff
他のプロジェクトについても適宜加味して下さい。

準備 1. Andorid での音声入出力処理を覚える

プラットフォームにより開発環境や API に違いはあっても音を扱う処理の本質は変わりません。手元でターゲットとして Android を選んだのは以下の理由によるものです。

  • メジャーである
  • サウンド I/O を標準で利用できる(マイコン系と比べての利点)
  • ポータブルであるため応用が利きやすく IoT 方面との連携性も良い(PC と比べての利点)
  • 開発環境の制約ごとが相対的に少ない(他のスマホ系と比べての利点)

まず、今回使用した音声入出力 API の情報をリファレンスより引用します。特に難しい要素もなく分かりやすい内容でした。

入力:AudioRecord クラス

  • AudioRecord
    The AudioRecord class manages the audio resources for Java applications to record audio from the audio input hardware of the platform.
          :
    Upon creation, an AudioRecord object initializes its associated audio buffer that it will fill with the new audio data.
          :
    Data should be read from the audio hardware in chunks of sizes inferior to the total recording buffer size.
    • AudioRecord (constructor)
      AudioRecord (int audioSource,
            int sampleRateInHz,
            int channelConfig,
            int audioFormat,
            int bufferSizeInBytes)
            :
      Parameters
            :
      bufferSizeInBytes
      int: the total size (in bytes) of the buffer where audio data is written to during the recording. New audio data can be read from this buffer in smaller chunks than this size. See getMinBufferSize(int, int, int) to determine the minimum required buffer size for the successful creation of an AudioRecord instance. Using values smaller than getMinBufferSize() will result in an initialization failure.
    • getMinBufferSize
      int getMinBufferSize (
            int sampleRateInHz,
            int channelConfig,
            int audioFormat)

      Returns the minimum buffer size required for the successful creation of an AudioRecord object, in byte units.
            :
    • startRecording
      void startRecording ()

      Starts recording from the AudioRecord instance.
    • read
      int read (short[] audioData,
            int offsetInShorts,
            int sizeInShorts)

      Reads audio data from the audio hardware for recording into a short array. The format specified in the AudioRecord constructor should be ENCODING_PCM_16BIT to correspond to the data in the array.
            :
    • getRecordingState
      int getRecordingState ()

      Returns the recording state of the AudioRecord instance.

      See also:
            RECORDSTATE_STOPPED
            RECORDSTATE_RECORDING
    • stop
      void stop ()

      Stops recording.

出力:AudioTrack クラス

  • AudioTrack
    The AudioTrack class manages and plays a single audio resource for Java applications. It allows streaming of PCM audio buffers to the audio sink for playback.
          :
    An AudioTrack instance can operate under two modes: static or streaming. In Streaming mode, the application writes a continuous stream of data to the AudioTrack, using one of the write() methods.
          :
    The static mode should be chosen when dealing with short sounds that fit in memory and that need to be played with the smallest latency possible.
          :
    • AudioTrack (constructor)
      AudioTrack (
            int streamType,
            int sampleRateInHz,
            int channelConfig,
            int audioFormat,
            int bufferSizeInBytes,
            int mode)
            :
      Parameters
            :
      int: the total size (in bytes) of the internal buffer where audio data is read from for playback. This should be a nonzero multiple of the frame size in bytes.
            :
      If the track's creation mode is MODE_STREAM, this should be the desired buffer size for the AudioTrack to satisfy the application's latency requirements. If bufferSizeInBytes is less than the minimum buffer size for the output sink, it is increased to the minimum buffer size.
            :
      See getMinBufferSize(int, int, int) to determine the estimated minimum buffer size for an AudioTrack instance in streaming mode.
    • play
      void play ()

      Starts playing an AudioTrack.

      If track's creation mode is MODE_STATIC, you must have called one of the write methods(中略)prior to play().

      If the mode is MODE_STREAM, you can optionally prime the data path prior to calling play(), by writing up to bufferSizeInBytes (from constructor). If you don't call write() first, or if you call write() but with an insufficient amount of data, then the track will be in underrun state at play(). In this case, playback will not actually start playing until the data path is filled to a device-specific minimum level.
            :
    • write
      int write (
            short[] audioData,
            int offsetInShorts,
            int sizeInShorts)

      Writes the audio data to the audio sink for playback (streaming mode), or copies audio data for later playback (static buffer mode). The format specified in the AudioTrack constructor should be ENCODING_PCM_16BIT to correspond to the data in the array.
            :
    • getPlayState
      int getPlayState ()

      Returns the playback state of the AudioTrack instance.

      See also:
            PLAYSTATE_STOPPED
            PLAYSTATE_PAUSED
            PLAYSTATE_PLAYING
    • pause
      void pause ()

      Pauses the playback of the audio data. Data that has not been played back will not be discarded. Subsequent calls to play() will play this data back. See flush() to discard this data.
            :
    • stop
      void stop ()

      Stops playing the audio data.
            :
    • flush
      void flush ()

      Flushes the audio data currently queued for playback. Any data that has been written but not yet presented will be discarded. No-op if not stopped or paused, or if the track's creation mode is not MODE_STREAM.
            :

試作

Android 環境での音声入出力の基本的な作法を覚えるために最初にごくシンプルなアプリを書いてみました。 入力側と出力側は多くの場合機能的に分けられますが、ここではその両方の要素を次の内容で単一のプログラムに組み入れています。

  • UI 上のボタンを押下するとマイクロフォンからの録音を開始する
  • 再度ボタンを押下すると録音を終了しその内容を再生する
  • 音声データはメモリ上で扱いファイルは使用しない

処理のイメージ

   

動作の様子

動画:33秒  ※音量注意!

ソースコード

sonic01     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic01
     *
     * 端末のマイクから音声を録音しスピーカーで再生する
     * Android 標準の AudioRecord, AudioTrack を使用
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic01;
    
    import android.Manifest;
    import android.content.pm.PackageManager;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v4.app.ActivityCompat;
    import android.support.v4.content.ContextCompat;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.ProgressBar;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final int BLOCK_NUMBER = 300;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
      private AudioTrack mAudioTrack = null;
    
      private Button mButton01;
      private ProgressBar mProgressBar;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mPlayBuf[];
      private short mRecordBuf[];
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mProgressBar = (ProgressBar)findViewById(R.id.progressBar);
        mProgressBar.setMax(BLOCK_NUMBER);
        mProgressBar.setProgress(0);
    
        /***** add for Android 6.0 or later ****/
        // https://developer.android.com/training/permissions/requesting.html
        // https://developer.android.com/topic/libraries/support-library/index.html#backward
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
            PackageManager.PERMISSION_GRANTED) {
          // RECORD_AUDIO の実行時パーミッションを要求
          ActivityCompat.requestPermissions(this,
              new String[]{Manifest.permission.RECORD_AUDIO}, 1);
        }
        initAudio();
        /**** add end ****/
    
    /***** move to initAudio() ****
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
        // 再生用バッファ
        mPlayBuf = new short[mBufferSizeInShort * BLOCK_NUMBER];
    
        // 録音用
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
    ****/
      }
    
      /**** add for Android 6.0 or later ****/
      // https://developer.android.com/training/permissions/requesting.html
      @Override
      public void onRequestPermissionsResult(int requestCode,
                           String permissions[], int[] grantResults) {
        switch (requestCode) {
          case 1: {
            if (grantResults.length > 0
                && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
              initAudio();
            } else {
              finish();
            }
            return;
          }
        }
      }
    
      private void initAudio() {
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
        // 再生用バッファ
        mPlayBuf = new short[mBufferSizeInShort * BLOCK_NUMBER];
    
        // 録音用
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
            SAMPLE_RATE,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSizeInBytes);
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
            SAMPLE_RATE,
            AudioFormat.CHANNEL_OUT_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSizeInBytes,
            AudioTrack.MODE_STREAM);
      }
      /**** add end ****/
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setEnabled(false);
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setEnabled(true);
            mButton01.setText("START");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        int count = 0;
        while (mInRecording && !mStop) {
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          // 再生用バッファはリングバッファとして扱う
          if (count * mBufferSizeInShort >= mPlayBuf.length) {
            count = 0;
            mProgressBar.setProgress(0);
          }
          // 再生用バッファへ集音したデータをアペンド
          System.arraycopy(mRecordBuf, 0, mPlayBuf, count * mBufferSizeInShort, mBufferSizeInShort);
          mProgressBar.setProgress(++count);
        }
        // 集音終了
        mAudioRecord.stop();
        mProgressBar.setProgress(0);
        mHandler.sendEmptyMessage(MSG_RECORD_END);
        if (mStop) {
          return;
        }
        // 再生
        mAudioTrack.setPlaybackRate(SAMPLE_RATE);
        mAudioTrack.play();
        mAudioTrack.write(mPlayBuf, 0, count * mBufferSizeInShort);
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic01">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

準備 2. 任意の周波数の音波を生成・出力する

音による情報伝達(言語音を除く)においては、伝えたい内容の意味を構成する要素として「音の高さ(周波数)」と「音の大きさ(振幅)」が挙げられます。これらを所定の規則に基づいて組み合わせることで送り手と受け手の間でのやりとりが可能となるでしょう。送り手側にはそこで任意の周波数・振幅の音を作る処理が必要となります。これがふたつめの課題です。

サイン波について

音の波形には種類がありますが、ここでは基本波形のひとつであるサイン波を使うことにしました。サイン波をプログラムで生成するためにその性質・特徴と関連する情報を整理してみます。

  • 正弦波 - wikipedia より
    正弦波(せいげんは、sine wave、sinusoidal wave)は、正弦関数として観測可能な周期的変化を示す波動のことである。その波形は正弦曲線(せいげんきょくせん、sine curve)もしくはシヌソイド (Sinusoid) と呼ばれ、数学、信号処理、電気工学およびその他の分野において重要な働きをする。       :
  • 正弦波交流って、実は円運動なんです - manapo.com 様のサイトより
    ある点を中心に回転する運動は「円運動」と言われ、 物理の世界ではよく出てくる言葉でもある。この円運動の軌跡を回転角度を横軸にしてグラフにすると、このように正弦波の形になる。
    角度が90°のところで山になり、180°のところで出発点と同じになり、270°のところで谷になり、360°のところで元に戻る。

    図では回転角度ではなく、少しでも理解しやすいように時間としてある。言い換えると、円運動で1回転するのに必要な時間を周期と呼ぶのである。そして、1秒間の回転数が即ち周波数である。
  •  https://www.youtube.com/embed/ij0K2tUVEGs - surikenclub 様による Youtube 動画
  • 第5章 フーリエ級数 - Masasi.Sanae 様のサイトより
    ・1秒間に描かれる波の回数を「周波数」といい,単位はヘルツ(Hz)を用います。
    ・1秒間に進む角度のことを「角速度」といいω(オメガ)で表します。
    ・横軸からの波の振れ具合を「振幅」といいます。

    sin波,cos波の1つの波が現れる角度(周期といいます)は360°ですから

        角速度=周波数×360°

    が成り立ちます。次の例では周波数が3,角速度1080°,振幅1のsin波を表しています。
    周波数k,振幅aのsin波はy=a sin kωtで表されます。(ω=360°)

    y=sin 1080°t=sin 3ωt

  • ラジアン(弧度法) - wikipedia より
    ラジアン(radian, 単位記号: [rad])は、国際単位系 (SI) における角度(平面角)の単位である。円周上でその円の半径と同じ長さのを切り取る2本の半径が成す角の値と定義される。

    1ラジアンは度数法では (180/π)° で、およそ 57.29578° に相当する。180°は弧度法においては π rad であり、360° は 2π rad である。
          :
    数学で三角関数を扱う時は、角度にラジアンを用いるのが普通である。その理由は、それが「自然」だからであり、多くの公式が簡潔に書けるようになる。

    (※半径 r の円の円周は 2 π r であるため 円(=360°)を弧度法で表すと「2 π r ÷ r」。よって 360° = 2π ラジアン)

以上のことから、振幅が A、角速度 ω のサイン波信号のある時点 t 秒における値 y は次の式によって得られます。

    y = A * sin ( ω * t )

ここで、角速度 = 周波数( f Hz )× 360° であり 360° = 2π ラジアン であるため角速度は「周波数( f Hz ) × 2π」と表せるためこの式は次のようにも書けます。

    y = A * sin ( 2π * f * t )

サンプリングレートについて

所定の周波数の信号を出力するためには相応のサンプリング(標本化)レートが必要となります。

オーディオ CD の規格で採用されているサンプリングレート 44,100Hz であれば理論上 22,000Hz 付近までの再現が可能であるため実用上の不足はないでしょう。また、十分なダイナミックレンジを確保するために振幅情報の量子化にはこれもオーディオ CD と同様に 16ビットを用いることとします。(ちなみに DVD は 24ビットだそうです)

計算してみると、「サンプリングレート 44,100Hz・量子化ビット数 16・モノラル」で 1秒間の信号を構成するには、44,100点 * 2バイト * 1チャネル = 88,200バイトのメモリ空間が必要ということになります。

関連情報

試作

以上の情報をもとにプログラムを試作しました。200Hz 〜 14,000Hz の範囲の 10種類の周波数のサイン波信号を生成・出力するものです。ここでは要素数 44,100 の short 型配列に 1秒分のサイン波データを格納しループ再生を行っています。

動作の様子

動画:2分13秒  ※音量注意!


分析に使用しているアプリ: AudioUtil Spectrum Analyzer - play.google.com

ソースコード

sonic03     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic03
     *
     * サイン波信号を生成して鳴らす
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic03;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.ToggleButton;
    
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
    
      private static final int [] FREQS =
          new int[] {200, 400, 800, 1600, 2000, 4000, 8000, 10000, 12000, 14000};
      private static final int DO = 262 * 2;
      private static final int RE = 294 * 2;
      private static final int MI = 330 * 2;
      private static final int FA = 349 * 2;
      private static final int SO = 392 * 2;
      private static final int RA = 440 * 2;
      private static final int SI = 494 * 2;
    
      private static final int MSG_PLAY_START = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButtons[];
      private int mIdxButtonPushed = -1;
      private int mBufferSizeInShort;
      private short mPlayBuf[];
    
      // 1秒分のサイン波データを生成
      private void createSineWave(int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(mPlayBuf, (short) 0);
        }
        for (int i = 0; i < SAMPLE_RATE; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          // y(t) = A * sin(2π * f * t)
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          mPlayBuf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButtons = new ToggleButton[11];
        (mButtons[0] = (ToggleButton)findViewById(R.id.button01)).setOnClickListener(this);
        (mButtons[1] = (ToggleButton)findViewById(R.id.button02)).setOnClickListener(this);
        (mButtons[2] = (ToggleButton)findViewById(R.id.button03)).setOnClickListener(this);
        (mButtons[3] = (ToggleButton)findViewById(R.id.button04)).setOnClickListener(this);
        (mButtons[4] = (ToggleButton)findViewById(R.id.button05)).setOnClickListener(this);
        (mButtons[5] = (ToggleButton)findViewById(R.id.button06)).setOnClickListener(this);
        (mButtons[6] = (ToggleButton)findViewById(R.id.button07)).setOnClickListener(this);
        (mButtons[7] = (ToggleButton)findViewById(R.id.button08)).setOnClickListener(this);
        (mButtons[8] = (ToggleButton)findViewById(R.id.button09)).setOnClickListener(this);
        (mButtons[9] = (ToggleButton)findViewById(R.id.button10)).setOnClickListener(this);
        (mButtons[10] = (ToggleButton)findViewById(R.id.button11)).setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
        // 再生用バッファ
        mPlayBuf = new short[SAMPLE_RATE]; // 1秒分のバッファを確保
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        for (int i = 0; i < mButtons.length; i++) {
          if (mButtons[i] == (ToggleButton)v) {
            mIdxButtonPushed = i;
            break;
          }
        }
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButtons[mIdxButtonPushed].isChecked()) {
          new Thread(this).start();
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButtons[mIdxButtonPushed].setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        mAudioTrack.play();
        if (mIdxButtonPushed != 10) {
          createSineWave(FREQS[mIdxButtonPushed], AMP, true);
          for (int i = 0; i < 5; i++) { // 5秒程度鳴らす
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
          }
        } else { // SONG
          for (int i = 0; i < 2; i++) {
            mAudioTrack.play();
            createSineWave(DO, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(FA, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(DO, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
    
            // 和音
            createSineWave(DO, AMP, true);
            createSineWave(MI, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            createSineWave(FA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            createSineWave(SO, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(FA, AMP, true);
            createSineWave(RA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            createSineWave(SO, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            createSineWave(FA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(DO, AMP, true);
            createSineWave(MI, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
          }
        }
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic03">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

スプレッドシートを使っての検証

前述のように上のコードの createSineWave 関数は要素数 44,100 の short 型配列に 44,100Hz・16ビット・モノラル 1秒分のサイン波データを生成しています。

この配列の先頭 441 要素分、つまり 1/100 秒分のデータとそれをグラフ化したものを以下に示します。それぞれ期待通りの波形が生成されていることが見てとれます。

  • 周波数 400Hz を指定した場合:400Hz/100 につき 4 周期分の出力
    element,value
    0,0
    1,227
    2,454
    3,680
    4,903
    5,1124
    6,1341
    7,1553
    8,1761
    9,1962
    10,2158
    11,2346
    12,2527
    13,2699
    14,2863
    15,3017
    16,3162
    17,3296
    18,3420
    19,3533
    20,3634
    21,3723
    22,3800
    23,3865
    24,3917
    25,3957
    26,3984
    27,3997
    28,3998
    29,3986
    30,3961
    31,3923
    32,3872
    33,3809
    34,3733
    35,3646
    36,3546
    37,3435
    38,3312
    39,3179
    40,3036
    41,2883
    42,2720
    43,2549
    44,2369
    45,2182
    46,1987
    47,1786
    48,1579
    49,1368
    50,1151
    51,931
    52,708
    53,483
    54,256
    55,28
    56,-199
    57,-426
    58,-652
    59,-876
    60,-1097
    61,-1314
    62,-1527
    63,-1735
    64,-1937
    65,-2134
    66,-2323
    67,-2505
    68,-2678
    69,-2843
    70,-2999
    71,-3145
    72,-3280
    73,-3405
    74,-3519
    75,-3622
    76,-3712
    77,-3791
    78,-3858
    79,-3911
    80,-3953
    81,-3981
    82,-3996
    83,-3999
    84,-3988
    85,-3965
    86,-3928
    87,-3879
    88,-3818
    89,-3743
    90,-3657
    91,-3559
    92,-3449
    93,-3328
    94,-3197
    95,-3055
    96,-2902
    97,-2741
    98,-2571
    99,-2392
    100,-2205
    101,-2012
    102,-1812
    103,-1606
    104,-1394
    105,-1179
    106,-959
    107,-736
    108,-511
    109,-284
    110,-56
    111,170
    112,398
    113,624
    114,848
    115,1069
    116,1287
    117,1501
    118,1709
    119,1913
    120,2110
    121,2300
    122,2482
    123,2657
    124,2823
    125,2980
    126,3127
    127,3264
    128,3390
    129,3506
    130,3610
    131,3702
    132,3782
    133,3850
    134,3905
    135,3948
    136,3978
    137,3995
    138,3999
    139,3990
    140,3968
    141,3934
    142,3886
    143,3826
    144,3753
    145,3669
    146,3572
    147,3464
    148,3344
    149,3214
    150,3073
    151,2922
    152,2762
    153,2592
    154,2415
    155,2229
    156,2036
    157,1837
    158,1632
    159,1421
    160,1206
    161,987
    162,764
    163,539
    164,313
    165,85
    166,-142
    167,-369
    168,-596
    169,-820
    170,-1042
    171,-1260
    172,-1474
    173,-1684
    174,-1887
    175,-2085
    176,-2276
    177,-2460
    178,-2636
    179,-2803
    180,-2961
    181,-3109
    182,-3247
    183,-3375
    184,-3492
    185,-3597
    186,-3691
    187,-3773
    188,-3842
    189,-3899
    190,-3944
    191,-3975
    192,-3994
    193,-3999
    194,-3992
    195,-3972
    196,-3939
    197,-3893
    198,-3834
    199,-3763
    200,-3680
    201,-3585
    202,-3478
    203,-3360
    204,-3231
    205,-3091
    206,-2941
    207,-2782
    208,-2614
    209,-2437
    210,-2253
    211,-2061
    212,-1862
    213,-1658
    214,-1448
    215,-1233
    216,-1014
    217,-792
    218,-567
    219,-341
    220,-113
    221,113
    222,341
    223,567
    224,792
    225,1014
    226,1233
    227,1448
    228,1658
    229,1862
    230,2061
    231,2253
    232,2437
    233,2614
    234,2782
    235,2941
    236,3091
    237,3231
    238,3360
    239,3478
    240,3585
    241,3680
    242,3763
    243,3834
    244,3893
    245,3939
    246,3972
    247,3992
    248,3999
    249,3994
    250,3975
    251,3944
    252,3899
    253,3842
    254,3773
    255,3691
    256,3597
    257,3492
    258,3375
    259,3247
    260,3109
    261,2961
    262,2803
    263,2636
    264,2460
    265,2276
    266,2085
    267,1887
    268,1684
    269,1474
    270,1260
    271,1042
    272,820
    273,596
    274,369
    275,142
    276,-85
    277,-313
    278,-539
    279,-764
    280,-987
    281,-1206
    282,-1421
    283,-1632
    284,-1837
    285,-2036
    286,-2229
    287,-2415
    288,-2592
    289,-2762
    290,-2922
    291,-3073
    292,-3214
    293,-3344
    294,-3464
    295,-3572
    296,-3669
    297,-3753
    298,-3826
    299,-3886
    300,-3934
    301,-3968
    302,-3990
    303,-3999
    304,-3995
    305,-3978
    306,-3948
    307,-3905
    308,-3850
    309,-3782
    310,-3702
    311,-3610
    312,-3506
    313,-3390
    314,-3264
    315,-3127
    316,-2980
    317,-2823
    318,-2657
    319,-2482
    320,-2300
    321,-2110
    322,-1913
    323,-1709
    324,-1501
    325,-1287
    326,-1069
    327,-848
    328,-624
    329,-398
    330,-170
    331,56
    332,284
    333,511
    334,736
    335,959
    336,1179
    337,1394
    338,1606
    339,1812
    340,2012
    341,2205
    342,2392
    343,2571
    344,2741
    345,2902
    346,3055
    347,3197
    348,3328
    349,3449
    350,3559
    351,3657
    352,3743
    353,3818
    354,3879
    355,3928
    356,3965
    357,3988
    358,3999
    359,3996
    360,3981
    361,3953
    362,3911
    363,3858
    364,3791
    365,3712
    366,3622
    367,3519
    368,3405
    369,3280
    370,3145
    371,2999
    372,2843
    373,2678
    374,2505
    375,2323
    376,2134
    377,1937
    378,1735
    379,1527
    380,1314
    381,1097
    382,876
    383,652
    384,426
    385,199
    386,-28
    387,-256
    388,-483
    389,-708
    390,-931
    391,-1151
    392,-1368
    393,-1579
    394,-1786
    395,-1987
    396,-2182
    397,-2369
    398,-2549
    399,-2720
    400,-2883
    401,-3036
    402,-3179
    403,-3312
    404,-3435
    405,-3546
    406,-3646
    407,-3733
    408,-3809
    409,-3872
    410,-3923
    411,-3961
    412,-3986
    413,-3998
    414,-3997
    415,-3984
    416,-3957
    417,-3917
    418,-3865
    419,-3800
    420,-3723
    421,-3634
    422,-3533
    423,-3420
    424,-3296
    425,-3162
    426,-3017
    427,-2863
    428,-2699
    429,-2527
    430,-2346
    431,-2158
    432,-1962
    433,-1761
    434,-1553
    435,-1341
    436,-1124
    437,-903
    438,-680
    439,-454
    440,-227
    
  • 周波数 1,600Hz を指定した場合:1,600Hz/100 につき 16 周期分の出力
    element,value
    0,0
    1,903
    2,1761
    3,2527
    4,3162
    5,3634
    6,3917
    7,3998
    8,3872
    9,3546
    10,3036
    11,2369
    12,1579
    13,708
    14,-199
    15,-1097
    16,-1937
    17,-2678
    18,-3280
    19,-3712
    20,-3953
    21,-3988
    22,-3818
    23,-3449
    24,-2902
    25,-2205
    26,-1394
    27,-511
    28,398
    29,1287
    30,2110
    31,2823
    32,3390
    33,3782
    34,3978
    35,3968
    36,3753
    37,3344
    38,2762
    39,2036
    40,1206
    41,313
    42,-596
    43,-1474
    44,-2276
    45,-2961
    46,-3492
    47,-3842
    48,-3994
    49,-3939
    50,-3680
    51,-3231
    52,-2614
    53,-1862
    54,-1014
    55,-113
    56,792
    57,1658
    58,2437
    59,3091
    60,3585
    61,3893
    62,3999
    63,3899
    64,3597
    65,3109
    66,2460
    67,1684
    68,820
    69,-85
    70,-987
    71,-1837
    72,-2592
    73,-3214
    74,-3669
    75,-3934
    76,-3995
    77,-3850
    78,-3506
    79,-2980
    80,-2300
    81,-1501
    82,-624
    83,284
    84,1179
    85,2012
    86,2741
    87,3328
    88,3743
    89,3965
    90,3981
    91,3791
    92,3405
    93,2843
    94,2134
    95,1314
    96,426
    97,-483
    98,-1368
    99,-2182
    100,-2883
    101,-3435
    102,-3809
    103,-3986
    104,-3957
    105,-3723
    106,-3296
    107,-2699
    108,-1962
    109,-1124
    110,-227
    111,680
    112,1553
    113,2346
    114,3017
    115,3533
    116,3865
    117,3997
    118,3923
    119,3646
    120,3179
    121,2549
    122,1786
    123,931
    124,28
    125,-876
    126,-1735
    127,-2505
    128,-3145
    129,-3622
    130,-3911
    131,-3999
    132,-3879
    133,-3559
    134,-3055
    135,-2392
    136,-1606
    137,-736
    138,170
    139,1069
    140,1913
    141,2657
    142,3264
    143,3702
    144,3948
    145,3990
    146,3826
    147,3464
    148,2922
    149,2229
    150,1421
    151,539
    152,-369
    153,-1260
    154,-2085
    155,-2803
    156,-3375
    157,-3773
    158,-3975
    159,-3972
    160,-3763
    161,-3360
    162,-2782
    163,-2061
    164,-1233
    165,-341
    166,567
    167,1448
    168,2253
    169,2941
    170,3478
    171,3834
    172,3992
    173,3944
    174,3691
    175,3247
    176,2636
    177,1887
    178,1042
    179,142
    180,-764
    181,-1632
    182,-2415
    183,-3073
    184,-3572
    185,-3886
    186,-3999
    187,-3905
    188,-3610
    189,-3127
    190,-2482
    191,-1709
    192,-848
    193,56
    194,959
    195,1812
    196,2571
    197,3197
    198,3657
    199,3928
    200,3996
    201,3858
    202,3519
    203,2999
    204,2323
    205,1527
    206,652
    207,-256
    208,-1151
    209,-1987
    210,-2720
    211,-3312
    212,-3733
    213,-3961
    214,-3984
    215,-3800
    216,-3420
    217,-2863
    218,-2158
    219,-1341
    220,-454
    221,454
    222,1341
    223,2158
    224,2863
    225,3420
    226,3800
    227,3984
    228,3961
    229,3733
    230,3312
    231,2720
    232,1987
    234,256
    235,-652
    236,-1527
    237,-2323
    238,-2999
    239,-3519
    240,-3858
    241,-3996
    242,-3928
    243,-3657
    244,-3197
    245,-2571
    246,-1812
    247,-959
    248,-56
    249,848
    250,1709
    251,2482
    252,3127
    253,3610
    254,3905
    255,3999
    256,3886
    257,3572
    258,3073
    259,2415
    260,1632
    261,764
    262,-142
    263,-1042
    264,-1887
    265,-2636
    266,-3247
    267,-3691
    268,-3944
    269,-3992
    270,-3834
    271,-3478
    272,-2941
    273,-2253
    274,-1448
    275,-567
    276,341
    277,1233
    278,2061
    279,2782
    280,3360
    281,3763
    282,3972
    283,3975
    284,3773
    285,3375
    286,2803
    287,2085
    288,1260
    289,369
    290,-539
    291,-1421
    292,-2229
    293,-2922
    294,-3464
    295,-3826
    296,-3990
    297,-3948
    298,-3702
    299,-3264
    300,-2657
    302,-1069
    303,-170
    304,736
    305,1606
    306,2392
    307,3055
    308,3559
    309,3879
    310,3999
    311,3911
    312,3622
    313,3145
    314,2505
    315,1735
    316,876
    317,-28
    318,-931
    319,-1786
    320,-2549
    321,-3179
    322,-3646
    323,-3923
    324,-3997
    325,-3865
    326,-3533
    327,-3017
    328,-2346
    329,-1553
    330,-680
    331,227
    332,1124
    333,1962
    334,2699
    335,3296
    336,3723
    337,3957
    338,3986
    339,3809
    340,3435
    341,2883
    342,2182
    343,1368
    344,483
    345,-426
    346,-1314
    347,-2134
    348,-2843
    349,-3405
    350,-3791
    351,-3981
    352,-3965
    353,-3743
    354,-3328
    355,-2741
    356,-2012
    357,-1179
    358,-284
    359,624
    360,1501
    361,2300
    362,2980
    363,3506
    364,3850
    365,3995
    366,3934
    367,3669
    368,3214
    369,2592
    370,1837
    371,987
    372,85
    373,-820
    374,-1684
    375,-2460
    376,-3109
    377,-3597
    378,-3899
    379,-3999
    380,-3893
    381,-3585
    382,-3091
    383,-2437
    384,-1658
    385,-792
    386,113
    387,1014
    388,1862
    389,2614
    390,3231
    391,3680
    392,3939
    393,3994
    394,3842
    395,3492
    396,2961
    397,2276
    398,1474
    399,596
    400,-313
    401,-1206
    402,-2036
    403,-2762
    404,-3344
    405,-3753
    406,-3968
    407,-3978
    408,-3782
    409,-3390
    410,-2823
    411,-2110
    412,-1287
    413,-398
    414,511
    415,1394
    416,2205
    417,2902
    418,3449
    419,3818
    420,3988
    421,3953
    422,3712
    423,3280
    424,2678
    425,1937
    426,1097
    427,199
    428,-708
    429,-1579
    430,-2369
    431,-3036
    432,-3546
    433,-3872
    434,-3998
    435,-3917
    436,-3634
    437,-3162
    438,-2527
    439,-1761
    440,-903
    

準備 3. 集音したデータの周波数分析を行う

FFT(Fast Fourier Transform 高速フーリエ変換)について

無響室のように人為的な特殊環境を除き世界は雑多な音で満ちています。人間の脳は自身が無意識のうちに複数の音の中から必要な音を選り分けており(カクテルパーティー効果)、静かに感じられる室内であってもレコーダーでの録音内容を再生すると意外なノイズに気づくといった経験は一般的なものですね。そのため、受け手側の装置が特定の音を検知するためには必要なものだけを明示的にピックアップする必要があります。これがみっつめの課題です。広く知られるフーリエ変換を音声信号に適用すれば音の塊を周波数成分ごとに分析できます。

フーリエ変換とは

  • フーリエ変換の本質 - apphokuson 様のサイトより

    全ての信号は、上図のように波の足しあわせで表現することが出来ます。

    具体的には、周波数が1の波1と周波数が2の波2と周波数が3の波3と・・・周波数が nの波nを足し合わせることで、あらゆる信号を表現することが出来るのです。
          :
    周波数を横軸に、振幅の大きさ(パワー値)を縦軸にとってグラフを書きなおしてみます。
          :
    この結果は、左図の信号に周波数1の波が大きさ4で含まれていること、周波数2の 波が大きさ0.5で含まれていること、周波数3の波が・・・ということを意味しています。
          :
    フーリエ変換を行えば、「信号に含まれる周波数の成分比」を得ることが出来ます。
          :

    (※引用に際し表記に一部手を加えさせて頂きました tanabe)

    • 統計的音声認識の基本原理 - 篠崎隆宏様のサイトより
      音声信号は,一次元の時系列データです.横軸を時間,縦軸を音の大きさとしてグラフを作成すると,以下のような図が得られます.

高速フーリエ変換とは

  • 高速フーリエ変換 - wikipedia より
    高速フーリエ変換(こうそくフーリエへんかん、英: Fast Fourier Transform、FFT) とは、離散フーリエ変換 (Discrete Fourier Transform、DFT) を計算機上で高速に 計算するアルゴリズム。FFTの逆変換をIFFT (Inverse FFT) と呼ぶ。
    • 離散フーリエ変換入門 - EnergyChord 様のサイトより
      コンピュータで行われるフーリエ変換は基本的に離散フーリエ変換である.なぜなら,電子計算機は連続量をそのまま扱うことができないため,コンピュータが処理するデータは必ず離散化される必要があるからである.つまり,コンピュータによるデータ処理が当たり前となった現代技術においては,フーリエ変換と言えば離散フーリエ変換を指すと言っても過言ではない.
      • 離散化とは - IT用語辞典バイナリより
        離散化とは、ある連続した情報を、非連続の値に分割することである。
        連続した値を持った情報を解析することは非常に困難であるが、離散化を行い非連続な数値に置き換えることで、近似的な計算結果を比較的容易に算出することが可能となる。
    • フーリエ変換と離散フーリエ変換 - 岩本真裕子様のサイトより
            :
      フーリエ変換では、波y(t)を構成する三角関数とその周波数・振幅・初期位相などを知ることができるため、波の特徴を明確に捉えることができる。

      離散フーリエ変換

      これまでは、連続関数に対するフーリエ変換およびフーリエ積分(逆フーリエ変換)について考えてきた。実際、音声や音楽などは、連続的な波として出力される。しかし、コンピュータは離散値でしか扱えないため、Analog-Didital (AD) 変換器を使って、一定間隔でサンプリングし、コンピュータが直接扱える離散的な波に変換される。
            :


      連続的な波と離散的な波の関係

      フーリエ変換は、連続的な波を三角関数を重ね合わせることで表そうとするものであった。例えば、下図のように、8個の周期関数を重ね合わせたものが実際の音であったとする。


      次に、AD変換によって、1秒間に4個のサンプリング数(4等分)で連続的な波が離散化されたとする(黒丸)。よく見てみると、cos 3t の離散の4点は cos t の離散の4点と全く同じである。実は sin 3t と sin t についても同様である。
      つまり、離散化された波においては、高周波数の波は低周波数の波に吸収されてしまう。今の場合は、重ね合わせられた複雑な波が、k=0,1,2 の三角関数のみで表せるということになってしまう。
      しかし、容易に予想できるように、サンプリング数を 増やすと、この問題は解消する。
            :
      離散フーリエ変換を用いた音などの解析や加工には、サンプリングは非常に重要な要素であり、扱う波についてある程度特徴や性質を知っている必要がある。例えば、人間が聞くことができる音の周波数は、20 Hzから20 kHzと言われている(一般的には15 kHzくらいまで)ので、人間が聞くことができるすべての周波数をカバーしようとすると、サンプリング数は1秒間あたり、20000(Hz)×2=40000 回必要となる。
            :

FFT ライブラリ「JTransforms」

Android には標準の Visualizer クラスに FFT 用の API getFft() が用意されています。ただし、あくまでも「The Visualizer class enables application to retrieve part of the currently playing audio for visualization purpose.」であり、「Frequency data: 8-bit magnitude FFT by using the getFft(byte[]) method」と機能的にも控えめな内容となっています。 Android で使える FFT ライブラリには複数の選択肢がありますが、Piotr Wendykier 氏による JTransforms が高性能かつメジャーであることを知りこれを利用することにしました。

  • JTransforms - Piotr Wendykier - sites.google.com
    JTransforms is the first, open source, multithreaded FFT library written in pure Java. Currently, four types of transforms are available: Discrete Fourier Transform (DFT), Discrete Cosine Transform (DCT), Discrete Sine Transform (DST) and Discrete Hartley Transform (DHT). The code is derived from General Purpose FFT Package written by Takuya Ooura and from Java FFTPack written by Baoshe Zhang.

    (Google 訳)
    JTransformsは、純粋なJavaで書かれた最初のオープンソースのマルチスレッド FFTライブラリです。現在、離散フーリエ変換(DFT)、離散コサイン変換(DCT)、 離散サイン変換(DST)および離散ハートレー変換(DHT)の4種類の変換が利用可能 である。このコードは、Takuya Ooura が書いた汎用FFTパッケージと、Baoshe Zhangが 書いたJava FFTPackから得られたものです。
          :
    License
    JTransforms is distributed under the terms of the BSD-2-Clause license.
  • wendykierp/JTransforms - GitHub
    • Documentation | Overview (JTransforms 3.1 API) - wendykierp.github.io
      JTransforms 3.1 API
      Packages
      Package                Description
      org.jtransforms.dct    Discrete Cosine Transforms.
      org.jtransforms.dht    Discrete Hartley Transforms.
      org.jtransforms.dst    Discrete Sine Transforms.
      org.jtransforms.fft    Discrete Fourier Transforms.
      org.jtransforms.utils  Utility classes.
      • Class DoubleFFT_1D
        org.jtransforms.fft
        Class DoubleFFT_1D

        Computes 1D Discrete Fourier Transform (DFT) of complex and real,double precision data. The size of the data can be an arbitrary number.

        (Google 訳)
        複素数と実数の倍精度データの一次元離散フーリエ変換(DFT)を計算します。データのサイズは任意の数にすることができます。
前掲のように音声信号は一次元データなので使うのはこの DoubleFFT_1D クラスでよさそうですが Javadoc では利用方法がさっぱりわからず使用例をネットで探しました。
  • JTransforms FFT in Android from PCM data - stackoverflow.com
      System.arraycopy(applyWindow(sampleData), 0, a, 0, sampleData.length);
      fft.realForward(a);

      /* find the peak magnitude and it's index */
      double maxMag = Double.NEGATIVE_INFINITY;
      int maxInd = -1;

      for(int i = 0; i < a.length / 2; ++i) {
        double re  = a[2*i];
        double im  = a[2*i+1];
        double mag = Math.sqrt(re * re + im * im);

        if(mag > maxMag) {
          maxMag = mag;
          maxInd = i;
        }
      }
  • How to get frequency from fft result? - stackoverflow.com
    The complex data is interleaved, with real components at even indices and imaginary components at odd indices, i.e. the real components are at index 2*i, the imaginary components are at index 2*i+1.

    To get the magnitude of the spectrum at index i, you want:

    re = fft[2*i];
    im = fft[2*i+1];
    magnitude[i] = sqrt(re*re+im*im);

    Then you can plot magnitude[i] for i = 0 to N / 2 to get the power spectrum. Depending on the nature of your audio input you should see one or more peaks in the spectrum.

    To get the approximate frequency of any given peak you can convert the index of the peak as follows:

    freq = i * Fs / N;

    where:

    freq = frequency in Hz
    i = index of peak
    Fs = sample rate (e.g. 44100 Hz or whatever you are using)
    N = size of FFT (e.g. 1024 in your case)

なるほど、使い方は難しくないようです。整理してみます。

  • 音声情報(=実数のみで構成される)を FFT にかける場合は信号データの配列を引数として realForward メソッドを呼び出す
  • 実行結果は配列に複素数で上書きされ偶数インデックスには実部、ひとつ後の奇数インデックスには虚部、というペアが続く
  • 各周波数成分の振幅値は当該複素数の絶対値re2+im2 で得られる
    • 複素平面 - KIT数学ナビゲーション様のサイトより
      xy 平面において, x 軸に実数, y 軸に虚数を対応させて,複素数を表したものを複素平面という.または,複素数平面,ガウス平面ともいう.

      複素数 z=a+ⅈb を複素平面上に表したものが,右の図である.

      複素数z の絶対値の定義:

      | z |=| a+b |= a 2 + b 2 =r

      すなわち,複素平面上の原点Oから z までの距離 r となる.
  • 周波数は「配列要素のインデックス * サンプリングレート / 信号データの要素数(=「FFT サイズ」)」の式で得られる
  • つまり、もっとも振幅の大きい周波数(ピーク周波数)を得るには最大振幅値を持つ配列要素のインデックスを上の式に突っ込めばよい

試作

上記の JTransforms ライブラリを利用して集音データ中のピーク周波数を画面に表示するアプリを作成。期待どおりの結果が得られました。

動作の様子

動画:1分7秒  ※音量注意!

ソースコード

sonic04     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic04
     *
     * 端末のマイクから音声を受信しピーク周波数を表示する
     * FFT 処理に JTransforms ライブラリを利用
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic04;
    
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_AMP = 0x00ff;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_FREQ_PEAK  = 120;
      private static final int MSG_SILENCE    = 130;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mFFTSize = mBufferSizeInShort;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_FREQ_PEAK:
            mTextView02.setText(Integer.toString(msg.arg1) + " Hz");
            break;
          case MSG_SILENCE:
            mTextView02.setText("");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_AMP) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            mHandler.sendEmptyMessage(MSG_SILENCE);
            continue;
          }
          int freq = doFFT(mRecordBuf);
          Message msg = new Message();
          msg.what = MSG_FREQ_PEAK;
          msg.arg1 = freq;
          mHandler.sendMessage(msg);
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列から各周波数成分の振幅値を求めピーク分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // 要素番号・サンプリングレート・FFT サイズからピーク周波数を求める
        return index * SAMPLE_RATE / mFFTSize;
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic04">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

次回の予定

以上の内容により Android デバイスで「任意の周波数・振幅の音を出すこと」と「拾った音に含まれる周波数を検知すること」ができるようになりました。音を使ってデバイス間で通信を行うための素材がこれで揃ったことになります。次回はここまでの到達点を応用してビットデータの送受信を試みます。


(tanabe)
klab_gijutsu2 at 07:21|この記事のURLComments(4)
2017年05月18日

音を利用する 1

はてなブックマークに登録

はじめに

技術・インフラの進化とともに情報通信の効率が加速を続ける状況にあって、「音」を利用した往年の通信手法が近年あらためて注目されています。関連する話題をいくつか以下にピックアップしてみます。これらはいずれも最新の技術や文化に密着した要件での応用という点で共通しており、懐古的なモチーフとはまったく無縁であることが興味深く感じられます。

(※各引用文中の青字表示は筆者によるものです)

  • お買い物体験をあたらしく
    株式会社スポットライト様サイトより)
    スポットライトの技術

    (1) 位置検出技術
    独自の超音波技術により、Andorid/iOS双方に対応した、正確でセキュアな来店検知が実現できます

  • 『SSC』Smart Sonic Communication 超音波通信のご案内
    ドリームニュース様サイトより: 2012-06-20)
    SINGAPORE SDI PTE LTD(本社:シンガポール)は、超音波を利用した機器間通信技術として『SSC(Smart Sonic Communication)』を開発いたしました。 iPhone、Androidなどのスマートフォンはもとより、マイクとスピーカーを備えている機器であれば特別なハードウェアを追加することなく、ソフトウェアの導入のみで通信が可能です。

  • 店舗にチェックインすればポイントが貯まる「楽天チェック」はO2Oの本命となるか
    TechCrunch Japan 様サイトより: 2014-04-02)
    超音波を利用した来店検知ポイントアプリスマポ」を提供するスポットライト。 2013年10月に楽天が買収して完全子会社となった同社が、4月2日より楽天と連携した来店ポイントアプリ「楽天チェック」の提供を開始した。

  • 楽天チェック - 来店でポイントが貯まる!お得な無料アプリ!
    (Google Play ストア上の株式会社スポットライト様によるアプリケーション説明より: 当記事時点での更新日付表示は 2017-04-23)
    1.楽天チェックでポイントが貯まるお店を探す
    2.好きなお店や気になったお店に行く
    3.お店に着いたらアプリに記載された指定のエリアでボタンをタップするだけ!

  • LINEの「友だち追加」で超音波が使用可能に!ネタかと思ったらほんとに出来た!
    「デジさる」様サイトより: 2015-09-06)
    LINEを起動して左下の「その他」から「友だち追加」をタップします。
    「QRコード」をタップします。
    するとQRコードリーダーが起動します。友だち追加をする一方はこの状態で待機します。
    もう一方は、「自分のQRコードを表示」をタップしてQRコードを画面に出します。
    すると音量がこの時だけ大きくなります。人間の耳には聞こえませんがこの時既に超音波が出ているようです。

  • Radon - Share using Ultrasound
    (Google Play ストア上の Nam Nghiem 様によるアプリケーション説明より: 当記事時点での更新日付表示は 2016-03-29)
    NO NFC? NO PROBLEM.
    Radon uses sensors you already have, and everyone else has including Wi-Fi, Bluetooth and Ultrasound. It does everything automatically, so all you have to do is tap share.

  • 新しい Amazon Dash Button にマイクが残されている理由
    (当ブログの記事より: 2016-12-22)
    旧 Button が iOS 端末でのセットアップ時に音声信号を利用するためにマイクロフォンを内蔵していたことは理解できます。では、なぜ BLE 通信を利用する新 Button にもマイクが残されているのでしょう?
    使わない部品であれば製造コスト削減のためにも撤去するほうが合理的なはずです。まさか Amazon が何か良からぬことを企んでいるのでしょうか?

      ・ Amazon Dashからマイクが見つかる、これ盗聴器だろ - jisaka.blog.jp

  • 新ThinkPadではエラービープ音をAndroidアプリに聞かせるとエラー内容の特定が可能
    PC Watch 様サイトより: 2017-02-09)
    2017年モデルの新機能の1つとして、エラービープ音の進化が挙げられる。 これまでのPCのエラービープ音と言えば、長い音と短い音を組み合わせたモールス信号のようなもので、ユーザーはその音を聞いて、マニュアルと照らし合わせてエラーを特定するといった煩雑な作業が必要だった。

    一方、2017年モデルでは、個別の音階を持ったエラー音でエラーコードを発信し、スマートフォン対応の「Lenovo PC Diagnostics」(リンク先は Android版)というアプリに聞かせると、エラーコードとともに、機種名、エラーの原因、タイムスタンプ、そしてモデル名やシリアルナンバーに至るまでを特定できるようになった。これによって管理の利便性を大幅に 向上させた。

事例では人間の可聴域を離れた超音波を利用するケースがしばしば見受けられます。実際、「超音波通信」は人気のあるキーワードですね。もっとも、かつて電話回線ごしの音響カプラや旧世代のファクシミリでの応酬に可聴音が用いられてきたように、使用する音域を決定づけるのは利用環境等の条件の組み合わせにすぎませんから、技術者にとって一番の関心事はあくまでも「音を媒体としてどのように情報をやりとりするか」という本質的な話題でしょう。

電磁波に比べ音波には伝送上の距離や効率により多くの制約があります。その一方で、法規制の影響がほぼ皆無であることに加え、機器に必要な I/F は音声入出力(役割によっては片方でよい)のみであり自由度が高いという大きな特長があります。このような事情に関心を持ち、近距離通信手段の選択肢のひとつとして「音」を利用するノウハウに触れておきたいと考えました。

そんなわけでここしばらく手元では Android デバイスと mbed マイコンをプラットフォームとしてそれぞれの環境で音(超音波を含む)による情報通信の実験を行ってきました。実践的な情報が比較的乏しい状況も相まって興味深い経験でしたが、全体のボリュームがやや大きいこともあり今後中身を整理しながら数回に分け順を追ってこのブログに記事を書きたいと考えています。

今回は予告をかねて試作の一部を紹介します。以下の二本の動画をご覧下さい。

  • 例1:音による通信で二台の端末に「しりとり」をさせる   

  • 例2:電話の着信音をトリガーにマイコンで所定の処理を実行する   

    次回は Android 環境での取り組みと実装まわりの話題を掲載する予定です。

    雑記:最初の最初に試したこと 〜 所定の音に反応する機器

    2004年ごろ、東京駅八重洲地下街にまだ存在していたアイディアグッズの店「王様のアイディア」で「リモコン発見器」というネーミングの商品を購入した。付属の笛を吹くと小さな本体が音と光で反応するというもの。

    何度か使ってずっと忘れていたが今回ふと思い出しネットで探したところ現在も販売されていることを知った。音に関する予備知識がほとんどない状態なりに一連の取り組みの皮切りとしてこのデバイスを使って観察を試みた。

    • リモコン発見器
      旭電機化成株式会社様サイトより)
      リモコンに装着し笛又は口笛を吹けばピッピッと音と光でリモコンがお知らせします。
      特徴
      よく使うリモコンに、付属テープで発信機をセット。
      笛又は、口笛を吹けばピッピッと音と光でリモコンがお知らせします。
    • 音と光で行方不明のリモコンをお知らせ! スマイルキッズ リモコン発見器 ARS-11 - www.amazon.co.jp 当記事時点の販売価格は¥378
    • 説明書
      「本製品は、約1600〜2000ヘルツの音に反応するよう作られていますので、笛以外の音でも周波数が合えば反応します」
    • 中身
      写真左:下の丸い圧電素子がスピーカーとマイクを兼ねておりメーカーへ尋ねたところ「特定の音域のみへの反応はフィルタ処理ではなく圧電素子の周波数特性によるもの」とのこと。
    • 動作の様子
      付属の笛・音楽キーボード・口笛を使い、反応する周波数帯を確認しながらこのデバイスを操作した様子。
      動画:1分26秒  ※38秒あたりから耳に障る音が鳴るので音量に注意
      使用アプリ: AudioUtil Spectrum Analyzer - play.google.com
        
    • それぞれの音の特徴
      以下は動画撮影後にあらためて採取した上記アプリのスクリーンショット。環境音も含まれるがもっとも振幅の大きい周波数成分が近いため三者の波形の周期は大体似ている。
      1. 製品付属の笛
        波形
        ピーク周波数は 2.0kHz 付近
      2. 音楽キーボード(CASIO 製 SA-5: 音色 "HARMONICA")
        波形
        ピーク周波数は 1.9kHz 付近
      3. 口笛
        波形
        ピーク周波数は 1.6kHz 付近

    (tanabe)
  • klab_gijutsu2 at 07:46|この記事のURLComments(0)TrackBack(0)
    2015年06月17日

    Android で今後ネイティブ実行形式を扱う際に注意すべきこと

    はてなブックマークに登録

    2010年の年末、このブログに以下の記事を掲載しました。

    Android NDK でネイティブ CUI プログラムを書く!
    

    当時は Android 2.3 を搭載した機種がぽつぽつ出始めたころでした。2015年6月現在の最新バージョンは Android 5.1.1 であり、Android 5.0/ 5.1 (Lollipop) からは PIE (Position Independent Executable) 以外のネイティブ実行形式がサポート外となったため注意が必要です。

    $ ./hello 
    error: only position independent executables (PIE) are supported.
    

    PIE にはプロセス空間上のどのアドレスに配置されてもメモリ上のアドレステーブル等を書き換えることなくそのまま実行できるという特長があり、アドレス空間レイアウトのランダム化(ASLR Address Space Layout Randomization)と組み合わせて利用すればプロセス上の所定のアドレスを想定したセキュリティ攻撃への対策として有効とされています。(ただし、PIE の生成・実行にはコンパイラと実行環境がこれに対応している必要があります)

    A look at ASLR in Android Ice Cream Sandwich 4.0 - www.duosecurity.com
    

    先年公開した Google Play ストア上のアプリ「stone for Android」には パケットリピータプログラム「stone」のネイティブ実行形式を梱入しています。ここしばらく Android から手が離れていたのですが、利用者の方からこのアプリが Android 5.0 環境で正常に動作しない旨のフィードバックを受けて上の事情を知り調査と対応を行いました。Lollipop で非 PIE を実行できないことは随所で言及されているものの、後方互換性やアプリでの対処方法に関する具体的な話題は意外と見あたらないため備忘をかねて以下に情報を控えます。

    まずは動作確認

    まず各プラットフォームで首実検を行いました。現時点で最新の NDK-r10 を使って stone と小さな "hello!" プログラムの PIE 版と 非 PIE 版をビルドしました。それを Android 各バージョンの ARM エミュレータ上で実行した結果を以下に示します。

    NDK用 プロジェクト一式 : stone & hello

    https://github.com/mkttanabe/stone-Android-NDK/stone
    
    https://github.com/mkttanabe/stone-Android-NDK/hello
    
    hello/jni/hello.c
    #include <stdio.h>
    
    int main()
    {
        puts("hello!");
        return 0;
    }
    
    hello/jni/Android.mk
    ※太字 2行で PIE をビルド、外せば非 PIE をビルド
    LOCAL_PATH := $(call my-dir)
    
    TARGET_PIE := false
    NDK_APP_PIE := false
    
    include $(CLEAR_VARS)
    
    LOCAL_CFLAGS += -fPIE
    LOCAL_LDFLAGS += -fPIE -pie
    
    LOCAL_MODULE    := hello
    LOCAL_SRC_FILES := hello.c
    
    LOCAL_LDLIBS += -ldl
    
    include $(BUILD_EXECUTABLE)
    
    hello/jni/Application.mk
    APP_PLATFORM := android-19
    
    # build for arm
    APP_ABI := armeabi
    
    # build for x86
    #APP_ABI := x86
    

    各プラットフォームでの実行結果

    • Android 5.0 (Lollipop) 以降は PIE のみに対応しており非 PIE の実行はブロックされる
    • Android 4.1 (Jelly Bean) 〜 Android 4.4 (KitKat) では PIE / 非 PIE のいずれも実行可
    • Android 4.0 (Ice Cream Sandwich) 以前のすべてのプラットフォームでは PIE を実行することができない
    hello hello (PIE) stone stone (PIE)
    5.1.1 (API 22) Lollipop "Not
    supported"
    OK "Not
    supported"
    OK
    5.0.1 (API 21) "Not
    supported"
    OK "Not
    supported"
    OK
    4.4W.2 (API 20) KitKat OK OK OK OK
    4.4.2 (API 19) OK OK OK OK
    4.3.1 (API 18) Jelly Bean OK OK OK OK
    4.2.2 (API 17) OK OK OK OK
    4.1.2 (API 16) OK OK OK OK
    4.0.3 (API 15) Ice Cream
    Sandwich
    OK segfault OK segfault
    4.0 (API 14) OK segfault OK segfault
    3.2 (API 13) Honeycomb OK segfault OK segfault
    3.1 (API 12) OK segfault OK segfault
    3.0 (API 11) OK segfault OK segfault
    2.3.3 (API 10) Gingerbread OK segfault OK segfault
    2.2 (API 8) Froyo OK segfault OK segfault
    2.1 (API 7) Eclair OK segfault OK segfault
    1.6 (API 4) Donut OK segfault OK segfault
    1.5 (API 3) Cupcake OK segfault OK segfault

    5.1.1 (API 22) 環境で実行時の様子

    root@generic:/data/local/tmp # ./hello 
    error: only position independent executables (PIE) are supported.
    
    root@generic:/data/local/tmp # ./hello_pie                                   
    hello!
    
    root@generic:/data/local/tmp # ./stone
    error: only position independent executables (PIE) are supported.
    
    root@generic:/data/local/tmp # ./stone_pie                                   
    May  1 10:40:28.556080 start (2.3e) [375]
    May  1 10:40:28.570865 stone 2.3e  http://www.gcd.org/sengoku/stone/
    May  1 10:40:28.572752 Copyright(C)2007 by Hiroaki Sengoku <sengoku@gcd.org>
    May  1 10:40:28.573123 using OpenSSL 1.0.1j 15 Oct 2014 http://www.openssl.org/
    Usage: ./stone_pie <opt>... <stone> [-- <stone>]...
    opt:  -h opt            ; help for <opt> more
          -h stone          ; help for <stone>
          -h ssl            ; help for <SSL>, see -q/-z opt
    

    4.1.2 (API 16) 環境で実行時の様子

    root@android:/data/local/tmp # ./hello                                       
    hello!
    
    root@android:/data/local/tmp # ./hello_pie                                 
    hello!
    
    root@android:/data/local/tmp # ./stone
    May  1 19:50:32.740789 start (2.3e) [647]
    May  1 19:50:32.756406 stone 2.3e  http://www.gcd.org/sengoku/stone/
    May  1 19:50:32.759034 Copyright(C)2007 by Hiroaki Sengoku <sengoku@gcd.org>
    May  1 19:50:32.760995 using OpenSSL 1.0.1c 10 May 2012 http://www.openssl.org/
    Usage: ./stone <opt>... <stone> [-- <stone>]...
    opt:  -h opt            ; help for <opt> more
          -h stone          ; help for <stone>
          -h ssl            ; help for <SSL>, see -q/-z opt
    
    root@android:/data/local/tmp # ./stone_pie                                   
    May  1 19:50:40.402476 start (2.3e) [651]
    May  1 19:50:40.412950 stone 2.3e  http://www.gcd.org/sengoku/stone/
    May  1 19:50:40.413214 Copyright(C)2007 by Hiroaki Sengoku <sengoku@gcd.org>
    May  1 19:50:40.415012 using OpenSSL 1.0.1c 10 May 2012 http://www.openssl.org/
    Usage: ./stone_pie <opt>... <stone> [-- <stone>]...
    opt:  -h opt            ; help for <opt> more
          -h stone          ; help for <stone>
          -h ssl            ; help for <SSL>, see -q/-z opt
    

    4.0.3 (API 15) 環境で実行時の様子

    # ./hello
    hello!
    
    # ./hello_pie
    [1] + Stopped (signal)        ./hello_pie
    [1]   Segmentation fault      ./hello_pie
    
    # ./stone
    May  1 10:53:22.265410 start (2.3e) [112]
    May  1 10:53:22.423641 stone 2.3e  http://www.gcd.org/sengoku/stone/
    May  1 10:53:22.425563 Copyright(C)2007 by Hiroaki Sengoku <sengoku@gcd.org>
    May  1 10:53:22.425869 using OpenSSL 1.0.0e 6 Sep 2011 http://www.openssl.org/
    Usage: ./stone <opt>... <stone> [-- <stone>]...
    opt:  -h opt            ; help for <opt> more
          -h stone          ; help for <stone>
          -h ssl            ; help for <SSL>, see -q/-z opt
    
    # ./stone_pie
    [2] + Stopped (signal)        ./stone_pie
    [2]   Segmentation fault      ./stone_pie
    

    アプリでの対処のしかた

    上の結果の通り、Android 4.0 以前の環境では PIE を実行することはできません。そのため、ネイティブ実行形式を含むアプリケーションを Lollipop 以降の環境と Ice Cream Sandwich 以前の環境の両方に対応させるためには相応の対処が必要となります。

    方法 1 : PIE と 非 PIE のふたつを使い分ける

    stone for Android の場合、アプリに内蔵しているネイティブの実行形式は stone 本体のみで、そのファイルサイズは 140KB 程度です。そのため アプリに PIE 版と非 PIE 版の両方を持たせ、起動時に当該プラットフォーム用の実行形式を切り出して使用するというもっとも素朴な対応を選びました。
    ※このアプリは現時点では ARM 専用です

    stone-for-Android/src/jp/klab/stone/stone.java#L103

                             :
    
        String MyDir = mUtil.GetMyResFileDirectory();
        if(Build.VERSION.SDK_INT >= 21) { // LOLLIPOP = android 5.0
            // only Position Independent Executables (PIE) are supported
            mUtil.ExtractMyResFile(R.raw.stone_pie, "stone", MyDir, "744");
        } else {
            // for backward compatibility
            mUtil.ExtractMyResFile(R.raw.stone, "stone", MyDir, "744");
        }
                             :
    

    方法 2 : "run_pie" ユーティリティを利用する

    run_pie は Android 4.0 (Ice Cream Sandwich) 環境で PIE を実行することを目的に Chromium プロジェクトの primiano@chromium.org 氏が開発したラッパプログラムです。手元にあるもっとも旧い Android 1.6 環境で試したところ、このツールを利用することで所定の PIE を正常に実行することができました。これをアプリに同梱し ICS 以前の環境で PIE を実行する際には そのフルパスと引数を run_pie へ渡してラップしてやれば互換性のジレンマをスマートに解決できそうです。

    Android 1.6 環境で実行した様子
    (直接 PIE を実行すると異常終了、run_pie 経由なら OK)

    # /data/local/tmp/hello_pie2
    [1] + Stopped (signal)        /data/local/tmp/hello_pie2
    [1]   Segmentation fault      /data/local/tmp/hello_pie2
    
    # ./run_pie /data/local/tmp/hello_pie2
    hello!
    
    #  /data/local/tmp/stone_pie2
    [1] + Stopped (signal)        /data/local/tmp/stone_pie2
    [1]   Segmentation fault      /data/local/tmp/stone_pie2
    
    # ./run_pie /data/local/tmp/stone_pie2
    Jun 17 08:02:07.942424 start (2.3e) [212]
    Jun 17 08:02:07.950579 stone 2.3e  http://www.gcd.org/sengoku/stone/
    Jun 17 08:02:07.950843 Copyright(C)2007 by Hiroaki Sengoku <sengoku@gcd.org>
    Jun 17 08:02:07.951066 using OpenSSL 0.9.8h 28 May 2008 http://www.openssl.org/
    Usage: /data/local/tmp/stone_pie2 <opt>... <stone> [-- <stone>]...
    opt:  -h opt            ; help for <opt> more
          -h stone          ; help for <stone>
          -h ssl            ; help for <SSL>, see -q/-z opt
    
    run_pie のソースは以下の場所にあります。
    Index of /trunk/src/tools/android/run_pie - src.chromium.org

    run_pie でラップして実行する PIE は以下のフラグを付与してビルドする必要があります。

    LOCAL_CFLAGS +=-fvisibility=default -fPIE
    LOCAL_LDFLAGS += -rdynamic -pie
    

    ※ビルドした PIE は Android 5.0 以上の環境ではもちろんそのまま実行できます

    ビルドずみの run_pie 本体と上記のフラグでビルドした stone, hello の PIE を以下の場所に置いています。

    https://github.com/mkttanabe/stone-Android-NDK/run_pie
    

    run_pie 開発者による書き込み:
    Issue 373219: Android binaries must now be PIE (but ICS doesn't support them) - code.google.com

    Project Member     Reported by primi...@chromium.org,    May 14, 2014
    
    Recent versions of Android require our native binaries 
    (forwarder, md5sum, adb_reboot, purge_ashmem, memdump) to be PIE.
    However, the same binaries must be also able to run on our bots 
    running previous versions of Android all the way down to ICS.
    Sadly, ICS doesn't seem to support PIE (see b/6587214 and crbug.com/147832).
                           :
    3) I managed to write a "run_pie" wrapper (cl coming soon) for supporting
    PIE on ICS. The idea is to just wrap commands with run_pie /actual/binary args.
    The (small) price to pay is two extra gyp flags (only for android tools exe
    targets) to force the PIE executable to export "main", so the pie wrapper
    can dlsym it (at least, as long as we'll support ICS).
    

    run_pie 利用者からのコメント:
    Issue 888: Blocking non-PIE binaries breaks the ABI - code.google.com

    #9 androtu...@gmail.com   Nov 7, 2014
    
    I've been shipping executable in my apps for about 4 years. Now I build all
    binaries with PIE option and bundle a "run_pie" non-PIE binary for older
    Android versions.
    
    This are the build option for all binaries except the run_pie:
    
    LOCAL_LDLIBS := -pie -rdynamic
    LOCAL_CFLAGS := -fPIE -fvisibility=default
    
    When PIE will be used as default build option, I'm not sure which option
    will disable it so I can still build the run_pie binary.
    
    So far it seems to be working fine for all my users. 
    
    That's where I found the run_pie information:
    https://code.google.com/p/chromium/issues/detail?id=373219
    

    方法 3 : Multiple APK を使用する

    Google Play ストアには、同一のアプリケーションについて所定の環境に対しそれぞれ実体の異なる所定の apk を配布することのできる Multiple APK という機能があります。Multiple APK を使用すると apk を開発・管理する上でのシンプルさが損なわれるため Google は可能な限り Single APK を使うことを推奨しており、下記ドキュメントの最初の注釈には「only when your APK is too large (greater than 50MB) 」とコメントを添えています。ただし、何らかの積極的な動機があればこの手法もまた PIE/ 非 PIE とプラットフォームの間の不整合を解決するための選択肢のひとつとなり得るかも知れません。

    公式ドキュメント:

    Multiple APK Support - developer.android.com
    
    参考訳:
    (tanabe)
    klab_gijutsu2 at 20:24|この記事のURLComments(3)TrackBack(0)
    2014年10月15日

    Dropbox アカウントひとつで利用できるプッシュ通知機構

    はてなブックマークに登録

    2018年6月追記: Dropbox API の仕様変更により以下の内容はすでに obsolete です。記事は残しますが、過去の情報であることをご了承下さい。

    Dropbox 社は広く知られるファイル系のサービスとは別に 2013年より非ファイル形式の構造化データの保存・読み出しに対応するデータストアサービスを公開しており、Dropbox アカウントを持っていれば Dropbox Datastore API 経由でこのサービスを利用できます。同 API は全体的にシンプルで SDK のサポート範囲も広いため自作のソフトウェアへ手軽に組み込むことが可能です。
    自前でサーバ環境を構築・運用する手間なしにレコードイメージのデータをネットワークストレージ上で取り回せるのは便利で、また多くの人がアカウントを持っていることへの安心感もあり、この Dropbox のデータストアサービスはさまざまな用途に柔軟に活用できそうです。

    Dropbox データストアの弱点としてストア上のデータのアカウント間での共有に未対応である点がしばしば挙げられていましたが、2014年9月に同社は New Datastore features! として Shared datastores のリリースをアナウンスしています。

    Dropbox Datastore API のデータ変更通知機能

    Dropbox Datastore API はアプリケーションをオフラインで操作した後のデータの自動同期や競合の解決を行うための機能や、ストア上のデータに変化があった際にそれをアプリへ通知するといった BaaS(Backend as a Service)風味の機能を備えています。

    後者はリスナとして登録した所定の関数をデータ変更発生時に API がコールバックするしくみで、そこでの通知を適切にハンドリングすることによりあるインスタンスが行ったデータ変更を他のインスタンスへすみやかに反映することが可能です。その実装例を Dropbox Datastore API の JavaScript SDK 付属のサンプルアプリケーションで試すことができます。公式のリンクを以下に掲載します。

    最初に表示される「Link to Dropbox」ボタンを押下して自分の Dropbox アカウントでログインすると UI が開きます。二台の PC の web ブラウザを使って操作を行うとわかりやすいでしょう。
    ※このアプリはログインしたアカウントのデータストア以外にはアクセスしません

    データストアの通知機構を単体で使用?

    サンプルアプリの動きはなかなか面白く新鮮に感じられました。反応も早くストアからの変更通知がアプリ内で適切に処理されている様子が見てとれます。

    ところで、「データに変更が発生したら変更内容をクライアントへ知らせる」という通知のしくみはこのデータストアの機能の構成要素のひとつとして用意された独自の機構であるわけですが、その部分は単独でも実用性があるように思いました。つまり、データストアを本来の記憶領域として使うことよりも、そこでのプッシュ通知のしくみを利用することを主目的とするアプリケーションもあり得るのではないかということです。

    単にストア上のデータを更新すれば通知処理が発動するシンプルさも好ましく、送信側は伝えたいメッセージの内容を所定のテーブルのレコードにデータとして乗せることでトリガーを送り、通知を受けた側はその内容に応じて所定の処理を行う形にすればよさそうです。もちろん機能的には専用のプッシュ通知機構に及びませんがメッセージの送受信さえできればあとはアプリ側の工夫次第でしょう。

    わざわざそんなことをしなくても所定のプラットフォームのスタンダードな通知機構を使えばよさそうなものですが、そういうことを考えたのには理由があります。プッシュ通知というものはごくプライベートな用途で使いたい場合もあります。たとえば、以前 Android の標準のプッシュ通知機構である GCM を利用して 自宅の遊休端末を留守中の監視カメラWake on Lan マジックパケット発信器として遠隔操作するためのアプリを手がけたことがあります。通知は正しく機能したものの、GCM はこういう小さな要件に軽く使うには必要な手順や手続きにいささか牛刀の感があり、処理用の中間サーバやサーバ用のコードなども面倒に思いながら仕方なく用意していました。

    だからと言って自分で似たしくみを作ることは保守・運用面で良い判断とも思えず、よくわからないサードパーティ製品に依存するのも気が進みません。あまり切実でも緊急でもないもののそういう緩いジレンマが時おり首をもたげていたのです。そんなわけで、なるべく手間をかけずシンプルに通知をハンドリングできるものがあればと思っていました。
    探せば他にも便利で面白いものが色々ありそうですが、別件でたまたま触れた Dropbox Datastore API の通知のしくみはちょうどそこにあてはまる気がしました。広範なプラットフォームで利用できることにも夢があります。

    ブラウザ用アプリを試作

    そんなわけで、まずは JavaScript サンプルを土台に手軽に実行できるブラウザ用アプリを作ってみることにしました。「URL プッシュ機能つきオンラインブックマークアプリもどき」とでも言うべきものです。
    ブラウザで下記の URL を開き Dropbox アカウントでログインすれば動かしてみることができます。もちろん自分のデータストア以外には一切アクセスしません(と言うかできません^^;)のでご安心下さい。

    使い方を図に示します。「選択されたページをこのブラウザで開く」ボックスをチェックしておくと、自分を含むいずれからのインスタンス上で「選択」または「追加」された URL が現在のブラウザで開かれます。

    ブラウザ版の動作の様子(動画 39秒 無音)

    左:Windows 上の Firefox      右:Mac 上の Safari

    Android 用アプリも試作

    手元での使用頻度の高い Android 端末用にもアプリを作ってみました。インストール用の QR コードを掲載します。

    使い方を図に示します。
    Android 版は受信専用です。「通知への待機を開始」ボタンを押下すると自前のサービスが起動し待機状態へ移行します。サービスはシステムから強制終了されにくいフォアグラウンドサービスとしており稼動中は端末の通知領域にアイコンが常駐します。サービスを停止するには「通知への待機を停止」ボタンを押下します。


    Android 版への通知はブラウザ版から行います。待機状態の Android 版は通知を受信すると次のように振舞います。
    • 通知内容が http(s)://... の形式なら URL とみなしブラウザへ渡す
    • それ以外なら Android 標準の am (activity manager) コマンドへのパラメータとみなし内容を整形して同コマンドへ渡す

    Android 版の動作の様子(動画 60秒 無音)

    左:Android 端末実機      右:Mac 上の Safari

    付録:am コマンド書式とパラメータ記述例

    $ am
    usage: am [subcommand] [options]
    
        start an Activity: am start [-D] [-W] <INTENT>
            -D: enable debugging
            -W: wait for launch to complete
    
        start a Service: am startservice <INTENT>
    
        send a broadcast Intent: am broadcast <INTENT>
    
        start an Instrumentation: am instrument [flags] <COMPONENT>
            -r: print raw results (otherwise decode REPORT_KEY_STREAMRESULT)
            -e <NAME> <VALUE>: set argument <NAME> to <VALUE>
            -p <FILE>: write profiling data to <FILE>
            -w: wait for instrumentation to finish before returning
    
        start profiling: am profile <PROCESS> start <FILE>
        stop profiling: am profile <PROCESS> stop
    
        start monitoring: am monitor [--gdb <port>]
            --gdb: start gdbserv on the given port at crash/ANR
    
        <INTENT> specifications include these flags:
            [-a <ACTION>] [-d <DATA_URI>] [-t <MIME_TYPE>]
            [-c <CATEGORY> [-c <CATEGORY>] ...]
            [-e|--es <EXTRA_KEY> <EXTRA_STRING_VALUE> ...]
            [--esn <EXTRA_KEY> ...]
            [--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE> ...]
            [-e|--ei <EXTRA_KEY> <EXTRA_INT_VALUE> ...]
            [-n <COMPONENT>] [-f <FLAGS>]
            [--grant-read-uri-permission] [--grant-write-uri-permission]
            [--debug-log-resolution]
            [--activity-brought-to-front] [--activity-clear-top]
            [--activity-clear-when-task-reset] [--activity-exclude-from-recents]
            [--activity-launched-from-history] [--activity-multiple-task]
            [--activity-no-animation] [--activity-no-history]
            [--activity-no-user-action] [--activity-previous-is-top]
            [--activity-reorder-to-front] [--activity-reset-task-if-needed]
            [--activity-single-top]
            [--receiver-registered-only] [--receiver-replace-pending]
            [<URI>]
    
    パラメータ記述例
    • start -n jp.klab.nopass/.MainActivity
      - 指定されたアプリのアクティビティを開く
    • start -a android.intent.action.VIEW -d geo:0,0?q="枕崎市"
      - 指定された場所をマップで表示
    • start -a android.intent.action.VIEW -d tel:000000000
      - 指定された電話番号の発呼準備
    • start -a android.intent.action.SENDTO -d mailto:nobody@example.com --es android.intent.extra.SUBJECT テスト --es android.intent.extra.TEXT インテント経由でメールを作ってみたり
      - 指定された宛先・件名・本文の新規メールを生成
    • start -a android.intent.action.VIEW -d vnd.youtube:qpjw62OK9Tw
      - 指定された動画を Youtube で再生

    余談ながら、、

    今回掲載したアプリ「NotifyIt」は Dropbox 社による事前審査を通過しています。


    (tanabe)
    klab_gijutsu2 at 14:03|この記事のURLComments(0)
    2014年08月06日

    「パスワードの管理を避ける」という考え方

    はてなブックマークに登録

    手持ちのアカウントが増えてくるとパスワードの管理方法が考えどころとなります。このところメジャーなネットサービスへの不正ログインやアカウントの悪用が相次いで報じられていることもあり パスワード管理の重要性は「古くて新しい問題」としてあらためて広い層に浸透しつつあります。

    その一方でパスワードをきっちり管理することは必ずしも簡単ではありません。平易な内容だと第三者によって類推・導出されるリスクが大きいものの複雑にすると覚えにくい。だからと言って複数のサービスで同じものを使いまわすとそれが漏洩した場合に一斉に攻撃を受ける危険がある。結局、安全度の高いパスワードをサービスごとに使い分けることは人間の記憶だけでは困難なので何らかの外部記憶を利用することになります。ただし、それが何らかのデバイスであれコンピュータデータの形式であれ、情報としてそこへ保存した時点で盗難・流出の可能性はゼロではなくなります。多くの場合、そういった諸々の事情を含んだ上で手元の実用上の便宜とのバランスを判断して管理方法を決めることになりますね。

    社内でそういう話をしていた時にふと思いました。一般に秘密文字列の内容は文字の並びとして意味をなさないものであるほど効果的なわけで、そのような情報はあまり「人間向き」とは言えないでしょう。もともと人間向きではない上に内緒にしておきたい情報を人間が丸ごと管理しようとすること自体に無理があるのではないか?その情報を直接管理せずに済ませることはできないか?生々しいパスワードをどこにも保管せずにすむのならそれにこしたことはありません。

    ソースコードと実行形式の関係を連想しました。プログラムのソースコードとそれをビルドする環境があればビルドずみの実行形式そのものを保持し続けることは必須ではありません。そのことと同様に所定の情報もまた必要な時に正しく用いることができれば十分であり、それを再現可能とする材料をあらかじめ揃えておけば情報そのものを恒常的に保持することを免れるはずです。
    その切り口で方法のひとつを考えてみました。一方向関数の応用です。

    • 利用者が任意のフレーズ (A) を灰色の変換器へ投入すると文字列 (D) が出力される
    • 処理 (B) の変換規則は決め打ちではなく、ランダムな内容で生成ずみの所定のフィルタ (C) を参照して決定される
    • (C) には (D) へ含める文字のバリエーションと (D) の文字列長を定義可能
    • 処理に際し利用者は任意の (C) を指定する
    • 固有の (A) (B) (C) の組合せは固有の (D) を生成する
    • (A) から (D) への変換は不可逆
    出力文字列 (D) は意味を持つ内容ではないためパスワード等の秘密文字列として使うことができます。一般的なパスワード生成器等で生成したパスワードは同じものを再発行できないためそれ自体を慎重に保管しておかなければなりませんが、この変換器では固有の (A) (B) (C) が揃っていれば常に固有の (D) を得ることが可能であるためその必要はありません。また、(C) のデータが単体で漏れたとしても正しい (A) (B) がなければ (D) を再現することはできません。さらに、この変換器は機能の性質上基本的に個人用であるため、処理 (B) の内容へ利用者が本人固有の要素を加味することを可能とするといった工夫も考えられそうです。

    このようにいたってシンプルな内容ですが、同様のアイディアが形になったものを今のところ知らずこうした話題には興味を感じます。前述のようにパスワードの管理に外部記憶を使わざるを得ない最大の理由は「それがパスワードとして適切な内容であればあるほど人の記憶に馴染まないから」という皮肉な事情にありますが、たとえばこういった方法なら人間の記憶というとても貴重なリソースを最大限に活かせるのではないか?という気がします。

    実装例

    試みに上のアイディアを Mac OS X 用 / Android 用アプリとして実装してみました。より便利に使うための機能を付加する余地が随所にあるものの この内容でも相応の実用性はありそうです。

    2018年6月追記:DropBox API の仕様変更に伴い Android 版アプリは一旦公開停止としています。現時点では改訂の目処が立っておらず、本アプリを利用して下さった皆様には大変ご迷惑をおかけしますが宜しくご了承下さい。

    • Mac OS X 版: NoPass100.dmg
      (535,655 bytes md5sum: 95bf7543509f4752b2da204fc545106b)
    • Android 版: NoPass - Google Play ストア
    • ソースコード: NoPass - GitHub

    • アプリでは前述 (C) のフィルタデータを「マップデータ」の呼称で統一しています
    • Max OS X 版は "~/Library/Application Support/NoPass/NoPass.dat" へマップデータを出力します
    • Dropbox 経由で複数の環境から同じマップデータを利用できます
    • Mac OS X 版はマップデータの 使用/ 作成/ 編集/ 削除/ アップロード/ ダウンロード/ 同期 が可能です
    • Android 版はマップデータの 使用/ ダウンロード のみが可能であり Mac OS X 版の併用と Dropbox アカウントが必要です

    • 動作の様子(動画 1分19秒 無音)
    • アプリ画面

    (tanabe)
    klab_gijutsu2 at 18:01|この記事のURLComments(0)
    2014年06月11日

    Android NDKで使えないシステムコール・ライブラリ関数一覧

    はてなブックマークに登録

    Android NDKでCのコードを書いていると、普段のCプログラミングでは悩まないことで悩むことがあります。たとえば、AndroidのlibcはGoogle製でPOSIXに準拠していません。他のUnix系環境であれば必ず実装されているライブラリ関数が存在しないなどの罠があるため、メジャーなツールをビルドするのにもconfigure;makeが素直に通らなかったりします。

    それだけでなく、Android NDKが提供する開発環境にも問題があります。特に、NDKで配られているヘッダファイルとビルド用の共有ライブラリで対応が取れていないのは頭痛のタネです。どういうことかというと、ヘッダファイルに定義されているシステムコールを使おうと思ったらリンカエラーが出ることがあります。

    また、システムコールの一部については、カーネルレベルでは正しく実装されているもののlibcにインターフェース実装がなく、呼びだすのに不便だったりもします。

    本稿では、この混乱した状況を多少なりとも整理してみます。根性系の調査結果なので、抜け漏れに気づいた方はぜひ教えてください。

    Android NDKとは

    まずAndroid NDKについて軽く紹介しておきます。

    Android NDK(Native Development Kit)はAndroid用のC/C++開発環境で、中身はAndroid向けのクロスコンパイラ、ヘッダファイル、ライブラリの環境一式です。x86、ARM、MIPS各アーキテクチャ用の実行バイナリや共有ライブラリを作れます。

    Androidアプリの開発はJavaで記述し、OSの機能へのアクセスはAndroid SDKが提供するJavaインターフェースを呼び出すのが標準的です。しかし、ネイティブコードを共有ライブラリの形で作成し、これをJavaから呼び出すこともできます。C/C++の資産を活用したり性能を稼いだりする場合のために提供されているのがNDKというわけです。

    bionic C library概要

    AndroidのカーネルはLinuxがベースになっています。LinuxのノウハウがそのままAndroidで利用できるとすればNDKのメリットも大きくなります。

    ところが、ここで問題になるのが冒頭で紹介したlibcの問題です。Androidでは各種Linuxディストビューションで一般的なglibcは採用されておらず、bionic C libraryと呼ばれるGoogle製のlibcが使われています。Googleがわざわざ独自のlibcを作った理由は筆者の理解では2点、性能面とライセンスの問題です。

    この2点のうち、性能面はわかりやすい理由でしょう。モバイルOSの場合、サーバー機と比べれば貧弱なCPUと少ないメモリで動作する必要がありますから、オーバースペックなglibcを嫌ってシンプルなlibcを作り直すという選択も理解できます。

    もう一点はライセンス上の問題です。筆者も理解度が低いのですが、スマートフォンベンダーやチップセットベンダーが一部のソフトウェア(デバイスドライバ類?)をクローズドソースにすることを許すために、ユーザー空間からGPL・LGPLのソフトウェアを排除する必要があったようです。glibcもuClibcもLGPLなので、新規実装するしか無かったというのも説得力があります。

    そんなわけで、多くのソースコードをBSD系から輸入しつつ、Linux系カーネルのシステムコールを呼び、一部は完全に新規実装したキメラ的なプロダクトがbionic C libraryです。今回指摘する内容もこのような出自によるところが大きそうです。

    ヘッダファイル中でコメントアウトされている関数一覧

    bionic C libraryではモバイルOSとして不要な機能はバッサリ削ってあります。ざっくり言うと、アカウント管理、システム管理などに関連する関数はヘッダファイル中でコメントアウトされており、実体も存在しません。

    以下はヘッダファイル中でコメントアウトしてあるシステムコール・ライブラリ関数の一覧です。

    • ctermid(3) - 制御端末名の取得
    • cuserid(3) - プログラムを実行しているユーザー名を取得する
    • endgrent(3) - グループファイルエントリの取得
    • endhostent(3)- ネットワーク上のホストのエントリを取得する
    • endnetent(3) - ネットワークエントリを取得する
    • endnetgrent(3) - ネットワーク・グループのエントリを操作する
    • endprotoent(3) - プロトコルのエントリを取得する
    • execvpe(3) - ファイルを実行する
    • freehostent(3) - ネットワークホストの名前とアドレスの取得
    • getdomainname(2) - NIS ドメイン名の取得・設定をする
    • getgrent(3) - グループファイルエントリの取得
    • getgrgid_r(3) - グループファイルエントリの取り出し
    • getgrnam_r(3) - グループファイルエントリの取り出し
    • gethostbyaddr_r(3) - ネットワーク上のホストのエントリを取得する
    • gethostbyname2_r(3) - ネットワーク上のホストのエントリを取得する
    • gethostent_r(3) - ネットワーク上のホストのエントリを取得する
    • getipnodebyaddr(3) - ネットワークホストの名前とアドレスの取得
    • getipnodebyname(3) - ネットワークホストの名前とアドレスの取得
    • getlogin_r(3) - このセッションにログインしているユーザー名を取得する
    • getnetbyaddr_r(3) - ネットワークエントリを取得する (リエントラント版)
    • getnetbyname_r(3) - ネットワークエントリを取得する (リエントラント版)
    • getnetent(3) - ネットワークエントリを取得する
    • getnetent_r(3) - ネットワークエントリを取得する (リエントラント版)
    • getnetgrent(3) - ネットワーク・グループのエントリを操作する
    • getprotobyname_r(3) - プロトコル エントリを取得する (リエントラント版)
    • getprotobynumber_r(3) - プロトコル エントリを取得する (リエントラント版)
    • getprotoent(3) - プロトコルのエントリを取得する
    • getprotoent_r(3) - プロトコル エントリを取得する (リエントラント版)
    • getpwent(3) - パスワードファイルのエントリの取得
    • getpwnam_r(3) - パスワードファイルのエントリの取得
    • getpwuid_r(3) - パスワードファイルのエントリの取得
    • getsid(2) - セッション ID を取得する
    • getsubopt(3) - 文字列中のサブオプション引き数の解釈を行う
    • innetgr(3) - ネットワーク・グループのエントリを操作する
    • on_exit(3) - プロセスが正常に終了した際に呼ばれる関数を登録する
    • pivot_root(2) - root ファイルシステムを変更する
    • setdomainname(2) - NIS ドメイン名の取得・設定をする
    • setfsgid(2) - ファイルシステムのチェックに用いられるグループ ID を設定する
    • setfsuid(2) - ファイルシステムのチェックに用いられるユーザ ID を設定する
    • setgrent(3) - グループファイルエントリの取得
    • sethostent(3) - ネットワーク上のホストのエントリを取得する
    • sethostname(2) - ホスト名の取得・設定をする
    • setnetent(3) - ネットワークエントリを取得する
    • setnetgrent(3) - ネットワーク・グループのエントリを操作する
    • setprotoent(3) - プロトコルのエントリを取得する
    • setpwent(3) - パスワードファイルのエントリの取得

    また、また、下記のセマフォや共有メモリに関するヘッダファイル、および対応するライブラリ関数は存在しません。

    • <sys/sem.h> /* SysV semaphores */
    • <sys/shm.h> /* SysV shared memory segments */
    • <sys/msg.h> /* SysV message queues */
    • <sys/ipc.h> /* General IPC definitions */

    これらが削られている理由はNDKのdocs/system/libc/SYSV-IPC.htmlにも書いてありますが、セマフォなどのシステムグローバルなリソースのリークがあった場合に、システムリブート以外の方法で解放できないのがリスクだから、ということのようです。

    ヘッダファイルに定義があるのにリンクできない関数一覧

    Android NDKで開発していると、ヘッダファイルにプロトタイプ宣言が存在するのにリンカがエラーを出すことがあります。例えば次のような状況になります。

    $ arm-linux-androideabi-gcc -Wall /tmp/bcmp-test.c
    /Users/hnw/Development/arm-android-19-toolchain/bin/../lib/gcc/arm-linux-androideabi/4.6/../../../../arm-linux-androideabi/bin/ld: /var/folders/_6/384fllzd5ys3mjqgk1xfmrnc0000gp/T//ccUhYkjo.o: in function main:bcmp-test.c(.text+0x34): error: undefined reference to 'bcmp'
    collect2: ld returned 1 exit status
    $
    

    <strings.h>にbcmpの定義があるのにリンクできないのは理不尽な気がしますが、実際NDKのlibc.soにはbcmpが含まれていないので仕方がありません。このようなシステムコール・ライブラリ関数は以下の通りです。

    • atexit(3) - プロセスが正常終了した時に呼び出される関数を登録する
    • bcmp(3) - バイト列を比較する
    • getw(3) - ワード(int)の入出力
    • malloc_usable_size(3) - obtain size of block of memory allocated from heap
    • mlockall(2) - メモリのロックとロック解除を行う
    • munlockall(2) - メモリのロックとロック解除を行う
    • pvalloc(3) - アラインメントされたメモリの割り当てを行う
    • rindex(3) - 文字列中の文字の位置を示す

    また、下記のロケール関連およびワイドキャラクタ系のライブラリ関数も同様の状況です。これらはヘッダ内に「サポートしてないけどlibstdc++-v3のコンパイルを通すために定義してある」的なことが書いてありますが、われわれ一般人からするとライブラリをビルドしたらコメントアウトしておいて欲しい気がします。

    • localeconv(3) - 数値に関する書式情報を得る
    • mblen(3) - 次のマルチバイト文字のバイト数を返す
    • mbtowc(3) - マルチバイト列をワイド文字に変換する
    • towctrans(3) - ワイド文字の変換
    • wctomb(3) - ワイド文字をマルチバイト列に変換する
    • wctrans(3) - ワイド文字変換マッピング

    これらはconfigureが混乱する原因になることがあります。例えば<locale.h>の存在チェックに成功するとロケール関連をデフォルトで有効にするものがありますので、明示的にconfigureオプションで無効にする必要があったりします。

    NDKのlibcには存在しないためリンクエラーになるシステムコール一覧

    システムコールはカーネル側で実装されており、カーネル側はほぼLinuxであるため、AndroidではLinuxのシステムコールの多くが呼び出せるはずです。しかし、実際には呼び出せないシステムコールが数多く存在します。これは、対応するCの関数がlibcに実装されていないためです。(例:「ftruncate64 linker error on NDK r8b」)

    このようなシステムコールを網羅的にリストアップする方法は思いつかなかったのですが、近いリストとして、Nexus5実機(Android 4.4)のlibcでは実装されているけれども、NDKのlibcには無いシステムコールを取り出してみました。

    • faccessat(2) - ユーザのファイルへのアクセス権をチェックする
    • fgetxattr(2) - 拡張属性の値を取得する
    • flistxattr(2) - 拡張属性の名前リストを得る
    • fremovexattr(2) - 拡張属性を削除する
    • fsetxattr(2) - 拡張属性の値を設定する
    • ftruncate64(2) - 指定した長さにファイルを切り詰める
    • getsid(2) - セッション ID を取得する
    • getxattr(2) - 拡張属性の値を取得する
    • lgetxattr(2) - 拡張属性の値を取得する
    • listxattr(2) - 拡張属性の名前リストを得る
    • llistxattr(2) - 拡張属性の名前リストを得る
    • lremovexattr(2) - 拡張属性を削除する
    • lsetxattr(2) - 拡張属性の値を設定する
    • mlockall(2) - メモリのロックとロック解除を行う
    • munlockall(2) - メモリのロックとロック解除を行う
    • perf_event_open(2) - set up performance monitoring
    • personality(2) - プロセスを実行するドメインを設定する
    • pread64(2) - 指定したオフセットでファイルディスクリプタを読み書きする
    • pwrite64(2) - 指定したオフセットでファイルディスクリプタを読み書きする
    • readahead(2) - 前もってファイルをページ・キャッシュに読み込む
    • removexattr(2) - 拡張属性を削除する
    • sched_getaffinity(2) - スレッドの CPU affinity マスクを設定・取得する
    • sched_setaffinity(2) - スレッドの CPU affinity マスクを設定・取得する
    • setxattr(2) - 拡張属性の値を設定する
    • signalfd(2) - シグナル受け付け用のファイルディスクリプタを生成する
    • signalfd4(2) - シグナル受け付け用のファイルディスクリプタを生成する
    • swapoff(2) - ファイル/デバイスへのスワップを開始/停止する
    • swapon(2) - ファイル/デバイスへのスワップを開始/停止する
    • tgkill(2) - スレッドにシグナルを送る
    • timerfd_create(2) - ファイルディスクリプタ経由で通知するタイマー
    • timerfd_gettime(2) - ファイルディスクリプタ経由で通知するタイマー
    • timerfd_settime(2) - ファイルディスクリプタ経由で通知するタイマー
    • unshare(2) - プロセス実行コンテキストの一部を分離する

    このリストは「arm-linux-androideabi-nm -D」でシンボルを取り出し、Linux環境で一般的と思われるシステムコール・ライブラリコールのみ抽出したものですので、過不足があるかもしれません。

    どうやらAndroid NDK付属のlibc.so(android-19プラットフォームなので、Android 4.4に対応)はかなり古いもののようで、bionic C libraryのリポジトリ上では2010年頃に修正されている内容が反映されていなかったりします。一方で、Nexus 5実機のlibc.soには多くの修正が反映されているようです。bionic C libraryの改善は続けるけど、Android NDKプログラミングでは後方互換性のために古いインターフェースで頑張ってね、というメッセージなのかもしれません。

    libcで実装されていないシステムコールを直接呼び出す方法

    ところで、システムコールの実体がカーネルに実装されていれば、libcに実装がなくても自分でCインターフェースを実装することができます。例えば、getsid(2)であれば次のようにして呼び出すことができます。

    #ifdef __BIONIC__
    #include 
    
    pid_t getsid(pid_t pid);
    pid_t getsid(pid_t pid) {
      return syscall(__NR_getsid, pid);
    }
    #endif
    

    syscall(2)システムコールは任意のシステムコールを呼び出すためのシステムコールで、第一引数にはシステムコール番号を渡します。ここで使われている__NR_getsidなどの各システムコール番号は<asm/unistd.h>で大量に定義されており、これを使って任意のシステムコールを呼び出すことができます。

    ただし、システムコール番号が定義されていても、正しく呼び出せる保証はありません。システムコールの実装本体はカーネルにあり、カーネル次第では未実装のこともあります。未実装の場合にはシステムコールがerrnoとしてENOSYSを返すことに注意してください。

    とはいえ、カーネルバージョンに対応したシステムコールであれば大抵うまく動く印象です。筆者はNexus 5(Android 4.4, Linux 3.4.0)でprocess_vm_readv(2)を呼び出すことができました。

    まとめ

    • Android NDKでは多くの環境で利用できるライブラリ関数であっても使えないものがあります
      • アカウント管理関連、システム管理関連、ロケール関連、ワイドキャラクタ系、セマフォ、共有メモリなど
    • Android NDKのヘッダファイル、NDKのlibc、実機のlibcで関数の対応が取れていないことがあります
    • libcで実装されていなくても、syscall(2)を使えば任意のシステムコールを呼び出すことが可能です

    本稿の内容は、筆者が各種Unix系ライブラリ・ツールをAndroid NDKでビルドしたときに気になった点をまとめたものです。makeがうまく通らないときなどに役立つと想像しています。

    ちなみに、Android NDKのlibc.soはプラットフォームandroid-9(Android 2.3相当)からandroid-19(Android 4.4相当)までほとんど変わっていないようです。ですから、上記内容はプラットフォームバージョンによらず共通の内容と言えそうです。


    @hnw
    klab_gijutsu2 at 19:13|この記事のURLComments(0)TrackBack(0)
    2014年04月21日

    スマホアプリと米国輸出規制に関するメモの続き

    はてなブックマークに登録

    前回の記事の続きです。今回は、前回の記事において米国商務省によるフローチャートで輸出規制への該非判定を行った iOS アプリ「stone for iOS」を App Store へ登録申請し、それが一度のリジェクトを経て公開されるまでの経緯を紹介します。

    記事へ引用の原文および参考訳は 2014年4月7日時点のものです
    ※引用文中の赤字・強調表示は筆者によるものです。「:」は引用の中略を示します

    許可例外「TSU」とは?

    さて、暗号を使用するオープンソースの iOS アプリ「stone for iOS」は、ソースコードが「一般に入手可能(publicly available)」であることから 米国輸出管理規則上の許可例外のひとつである「TSU」の適用が可能で、その措置により商務省の輸出許可を得ることなく米国内の App Store からの輸出が可能であることがわかりました。

    一連の許可例外は EAR Part 740 "License Exception"(参考訳:EAR Part 740 "許可例外")に定められています。「TSU」は「Technology and Software Unrestricted(規制されない技術及びソフトウェア)」の略です。前出のフローチャートの指示に添って §740.13(e) の内容を確認してみましょう。

    原文:§740.13 Technology and Software Unrestricted (TSU)(以下抜粋)

    § 740.13 TECHNOLOGY AND SOFTWARE UNRESTRICTED (TSU) 
     
    This license exception authorizes exports and reexports
    of operation technology and software; sales technology
    and software; software updates (bug fixes);“mass market”
    software subject to the General Software Note; and
    encryption source code (and corresponding object code) 
    that would be considered publicly available under
    §734.3(b)(3) of the EAR. 
    
                             :
    
    (e) Publicly available encryption source code. 
     
     (1) Scope and eligibility.  Subject to the notification
         requirements of paragraph (e)(3) of this section, 
         this paragraph (e) authorizes exports and reexports
         of publicly available encryption source code
         classified under   ECCN 5D002 that is subject to
         the EAR (see §734.3(b)(3) of the EAR).   
         Such source code is eligible for License Exception
         TSU under this paragraph (e) even if it is subject
         to an express agreement for the payment of a licensing
         fee or royalty for commercial production or sale of
         any product developed using the source code.   
      
     (2) Restrictions.  This paragraph (e) does not authorize: 
     
      (i) Export or reexport of any encryption software
          classified under ECCN 5D002 that does not meet the
          requirements of paragraph (e)(1), even if the
          software incorporates or is specially designed to
          use other encryption software that meets the
          requirements of paragraph (e)(1) of this section; or
    
      (ii) Any knowing export or reexport to a country listed
           in Country Group E:1 in Supplement No. 1 to part 740
           of the EAR.   
     
     (3) Notification requirement. You must notify BIS and
         the ENC Encryption Request Coordinator via e-mail of
         the Internet location (e.g., URL or Internet address)
         of the publicly available encryption source code or
         provide each of them a copy of the publicly available 
         encryption source code. If you update or modify the
         source code, you must also provide additional copies
         to each of them each time the cryptographic 
         functionality of the source code is updated or modified.
         In addition, if you posted the source code on the
         Internet, you must notify BIS and the ENC Encryption
         Request Coordinator each time the Internet location is 
         changed, but you are not required to notify them of
         updates or modifications made to the encryption source
         code at the previously notified location. In all
         instances, submit the notification or copy to
         crypt@bis.doc.gov and to enc@nsa.gov. 
    
    

    原文:EAR§734.3(b)(3) より
    EAR Part 734 "Scope of the Export Administration Regulations"

                             :
      Publicly available encryption object code software
      classified under ECCN 5D002 is not subject to the EAR
      when the corresponding source code meets the criteria
      specified in §740.13(e) of the EAR.  
    

    参考訳:§740.13 規制されない技術及びソフトウェア(TSU)(以下抜粋)

    §740.13 規制されない技術及びソフトウェア(TSU) 
    
    この許可例外は、使用に係る技術及びソフトウェア、販売に
    係る技術及びソフトウェア、ソフトウェアのアップデート
    (バグの修復) 、General Software Noteの対象となる
    "マスマーケット"ソフトウェア、並びに暗号ソースコード
    (及び対応するオブジェクトコード)であって、
    EAR §734.3(b)(3)のもとに一般に入手可能とみなされるものの
    輸出及び再輸出を是認するものである。 
    
                             :
    
    (e) 一般に入手可能な暗号ソースコード 
    
     (1) 適用範囲及び適格性 
         本節の(e)(3)項の届出要求事項を条件として、
         この(e)項は、ECCN 5D002のもとに番号分類される一般に
         入手可能な暗号ソースコードであって、EARの対象となる
         ものの輸出及び再輸出を是認する (EAR§734.3(b)(3)を
         参照のこと)。このようなソースコードは、たとえ、
         そのソースコードを用いて開発される製品の商売を目的
         とする製造又は販売のためにライセンス料金又は
         ロイヤリティの支払いに関する明確な契約に従う場合で
         あっても、この(e)項のもとに許可例外 TSUが適用できる。 
    
     (2) 制限事項 
         この(e)項は、次の(i)又は(ii)については是認しない: 
    
       (i) (e)(1)項の要求事項を満たさない ECCN 5D002 の
           もとに番号分類される暗号ソフトウェアの輸出
           若しくは再輸出(たとえ、そのソフトウェアが
           本節の(e)(1)項の要求事項を満たす他の暗号ソフト
           ウェアを組み込んでいたり、他の暗号ソフトウェア
           を使用するために特別に設計したものであっても
           同様である);又は 
    
       (ii) EAR§740 Supplement No.1 のカントリーグループ
            E:1に掲載されている国に輸出若しくは再輸出する
            ことを知っている場合。 
    
      (3) 届出要求事項 
         あなたは、一般に入手可能な暗号ソースコードの
         インターネットロケーション(例えば、URL若しくは
         インターネットアドレス)を電子メールで BIS及び
         ENC暗号請求コーディネータに届け出るか、或いは一般に
         入手可能な暗号ソースコードのコピーを両者のそれぞれに
         提供しなければならない。あなたがソースコードを
         アップデートするか変更する場合、あなたは、当該ソース
         コードの暗号機能がアップデート又は変更されるたびに、
         両者のそれぞれに追加のコピーを同様に提供しなければ
         ならない。それに加えて、あなたがインターネットに
         ソースコードを掲示する場合、インターネットロケーション
         が変わるたびに、BIS及び ENC 暗号請求コーディネータに
         届け出なければならない、しかし、以前に届け出た
         ロケーションにある暗号ソースコードに対して行うアップ
         デート又は変更については、両者に届け出る必要はない。
         すべての場合において、届出又はコピーを crypt@bis.doc.gov
         及び enc@nsa.govに提出しなさい。 
    
    

    参考訳:EAR§734.3(b)(3) より
    EAR Part 734 "EARの管轄範囲"

                             :
     ECCN 5D002 のもとに番号分類される一般に入手可能な暗号
     オブジェクトコードソフトウェアは、対応するソースコードが
     EAR§740.13(e)で指定される基準を満たしている場合、EARの
     対象とならない。
    

    「ソースコードを一般に入手可能な暗号ソフトウェアとその実行形式は、当該ソースコードのインターネットロケーションを電子メールで当局へ届け出れば輸出規制対象にならない」ということですね。なお、§740.13(e)(2)(ii) に言及のある「カントリーグループ E:1」は米国政府指定の「テロ支援国家」であり、これらの国から App Store を利用することはできません。

    暗号処理を内蔵・使用していても米国当局に対しメールでの届け出を行うだけで許可例外 TSU を適用できることはオープンソースソフトウェアの強みのひとつと言えるかもしれません。では、具体的にはどのようなメールを送ればいいのでしょう?

    TSU NOTIFICATION メールの書き方と送り方

    以前は米国商務省(BIS)のサイトに「Notification Requirements for "Publicly Available" Encryption Source Code("一般に入手可能な" 暗号ソースコードの届出要求事項)」という記事があり、そこにオープンソース暗号ソフトウェアのソースの場所を当局へ届け出るための要領が判りやすく説明されていたのですが、今回何年かぶりに上のリンクの URL へアクセスしてみるとご覧の通り「404 File Not Found」でした。また、サイト内をざっと検索したところでは新しい記事は見当たりませんでした。

    上の記事が削除された理由は判然としませんが(事情をご存知の方はお知らせ下さい)、内容は規則の一部ではなく届け出の要領の説明であり、別の記事を手早く見つけることができない以上、仮に多少旧くなっていたとしても届出義務そのものを果たすためにそこでの説明に準じることは当事者としての善意に基づく自然な判断と言えるでしょう。web.archive.org に残っている 2012-01-31 付の最後のキャッシュへのリンクを以下に掲載します。

    http://web.archive.org/web/20120131171318/http://www.bis.doc.gov/encryption/pubavailencsourcecodenofify.html

    (上の記事の全文)

    参考訳(筆者)

    "一般に入手可能な" 暗号ソースコードの届出要求事項
    
    このページ上のEARの引用箇所は、Government Printing Office の
    Webサイトの「December 9, 2004 Encryption Rule」で見つけること
    ができます
    
    ステップ1:輸出管理規則(EAR)の関連部分を読む
    
    このガイダンスは、許可例外TSUのもとで、一般に入手可能
    (EARセクション734.3(b)(3) 参照)とみなされる暗号ソース
    コードを輸出を進行する前の注意事項を提供するために設計
    されており、輸出管理規則 (EAR) の関連箇所と組み合わせて
    使用されるべきです。
    
    EARのセクション740.13 (e)は、許可例外TSUを使用して
    "一般に入手可能な"暗号ソースコードを輸出する際の主要な
    規制基準です。このセクションでは、ECCN 5D002 で規制
    されており、また、EAR セクション 734.3(b)(3) で一般に
    入手可能とみなされる暗号化ソースコードの輸出(例えば 
    インターネットへのポスト)と再輸出のための届出について
    説明します。こういった暗号化ソースコード(および対応
    するオブジェクトコード、供給されたオブジェクトコードも
    また一般に入手可能とみなされます)は、BIS(および
    ENC Encryption Request Coordinator)へインターネット
    ロケーション(例:URL または インターネットアドレス)
    または輸出時点のソースコードのコピーを届け出ることで、
    審査なしに輸出・再輸出されることがあります。
    
    一般に入手可能とみなされる暗号化ソースコードは、たとえ
    それが商業生産やそのソースコードを使って開発された製品の
    販売へのライセンス料やロイヤリティの明示的な同意を受ける
    場合においても、許可例外TSUのこの規定の対象となります。
    そうしたソースコードをコンパイルした結果の対応する
    オブジェクトコードも、それがまた一般に入手可能とされて
    いれば許可例外TSUの対象となります。
    たとえ一般に入手可能とみなされる暗号ソフトウェアに
    組み込まれていたりそれを使うことに特化されていても、
    一般に入手可能とはみなされない ECCN 5D002規制下の
    暗号ソフトウェアは、許可例外TSUのこの条項
    (セクション740.13(e))のもとに輸出や再輸出の対象とは
    なりません。
    
    ステップ2:URLロケーション(またはソースコードのコピー)の
               届け出を BIS および ENC Encryption Request
               Coordinator へ提出
    
    あなたは、輸出の時点でソースコードのインターネット
    ロケーション(例:URL またはインターネットアドレス)
    (またはソースコードのコピー)の記された届出を BIS へ
    提供しなければなりません。届け出を E-Mail で BIS へ提出し 
    ENC Encryption Request Coordinator へ写しを送ってください。
    
    [注:この段落内のリンクは、自動的に両方の電子メールを
     生成します]
    また、このガイダンスのページの末尾に記載されている
    アドレスにメールで届け出を提出することができます。
    
    BIS と ENC Encryption Request Coodinator の両方へ
    E-Mail で届け出のための書式指定:
    
    あなたの電子メールの件名に、次のように入力します。
    "TSU NOTIFICATION"
    [注:上記の電子メールのいずれかのリンクをクリックする場合、
    これは既に実行されています。]
    
    メールの本文に、次の情報を入力します。
    
    SUBMISSION TYPE: "TSU"と入力
    SUBMITTED BY:
    SUBMITTED FOR: (暗号品目を輸出する会社または個人の名前)
    POINT OF CONTACT:
    PHONE and/or FAX:
    MANUFACTURER: (該当する場合)
    PRODUCT NAME/MODEL #:
    ECCN: 5D002
    
    NOTIFICATION:ソースコードのURLまたはインターネットアドレス、
                  または他のソースコードのコピーを提供する
    
    FAXまたは手紙による通知による代替の書式の指定:
    (以下略)
    

    「stone for iOS」のソースコードを GitHub のリポジトリへコミットした後で以下の内容でメールを送りました。これでこのソフトウェアに許可例外 TSU を適用するために必要な届出要件は満たされました。ちなみにこれは「申請」ではなく「届出」なので送付先からのレスポンスはありません。

    From: Tanabe, Makoto
    Sent: Thursday, March 13, 2014 3:09 PM
    To: crypt@bis.doc.gov; enc@nsa.gov; web_site@bis.doc.gov
    Cc: 'Tanabe, Makoto (KLab Inc.)'
    Subject: TSU NOTIFICATION - Encryption
    
    
    SUBMISSION TYPE: TSU
    SUBMITTED BY: Tanabe, Makoto
    SUBMITTED FOR: KLab Inc.
    POINT OF CONTACT: K-Laboratory, KLab Inc.
    FAX: +81-3-▒░░▒-▒░░▒
    MANUFACTURER(S): The OpenSSL Project
    PRODUCT NAME/MODEL #: stone for iOS
    ECCN: 5D002
    
    NOTIFICATION:https://www.openssl.org/source/ (OpenSSL)
    NOTIFICATION:https://github.com/mkttanabe/stone-for-iOS (stone for iOS)
    NOTIFICATION:http://sourceforge.jp/cvs/view/stone/stone/ (stone)
    
    'stone for iOS' is an iOS application.
    'stone for iOS' is an open source software.
    'stone for iOS' is a GUI version of the open source packet repeater 'stone'.
    'stone for iOS' is static linked with the OpenSSL Library. 
    'stone for iOS' uses the OpenSSL functions.
    

    App Store への登録申請

    以上の手続きで「輸出」を行う準備が整いました。いよいよ「stone for iOS」を App Store へ登録申請します。アプリケーションの説明欄には以下のように簡単にアプリの内容と OpenSSL に関する表記、ソースコードの URL を書きました。

    一連のメタデータの記入を終えたあとで「Ready to Upload Binary」ボタンを押下し Export Compliance の確認画面へ移行します。

    上の設問の参考訳(筆者)

    Export Compliance

    あなたのアプリは暗号技術を使用するように設計されているか、あるいは暗号技術を含んだり組み込んでいますか?(たとえあなたのアプリが iOS や OS X で利用可能な暗号化のみを利用していても Yes を選んで下さい)

    あなたのアプリは米国輸出管理規則のカテゴリ 5, パート 2 で規定されたいずれかの適用除外の資格がありますか?

    あなたのアプリがここにリストされている適用除外の基準に適合していることを確認してください。 あなたはあなたのプロダクトの適切な分類に責任があります。あなたのアプリの不正確な分類は 米国輸出法違反につながる可能性があり、あなたのアプリが App Store から削除されることを含め あなたを処罰の対象と成し得ます。質問に答える前に十分に FAQ を読んで下さい。

    もしあなたのアプリの暗号化が次のいずれかであれば、あなたは 質問 #2 に Yes を選択できます:
    (a) 医療のエンドユースのために特別に設計されている
    (b) 知的所有権と著作権 の保護に限定されている
    (c) 認証、デジタル署名、またはファイルのデータの復号 に限定されている
    (d) 銀行業務または"金銭取引" のために限定され特別に設計されている;または
    (e) "固定式" のデータ圧縮または符号化技術 に限定されている

    あなたのアプリが米国輸出管理規則のカテゴリ 5, パート 2 の注 4 に規定された記述に適合している場合にもあなたは Yes を選択できます。

    適用除外に関するその他のガイダンスについては FAQ を参照してください。

    ※サインインの必要なページなので引用をさし控えますが、iTunes Connect の FAQ > Export Compliance はわかりやすく書かれた良い記事です

    最初の質問の「Yes」をチェックすると表示されるふたつめの質問 は、前回の記事で何度も見てきた米国商務省規制リストの「カテゴリ 5 パート 2」参考訳)の規定により当該アプリが規制の対象外となるか否かを問うものですね。
    カテゴリ 5 パート 2 の ECCN 5D002 のエントリより、オープンソースの暗号ソフトウェアを規制対象外と規定している箇所の原文と参考訳の抜粋を以下に示します。

    5D002 “Software” as follows
           (see List of Items Controlled)
    
                  :
    
    Note: Encryption source code classified under this entry
    remains subject to the EAR even when made publicly
    available in accordance with part 734 of the EAR. 
    However, publicly available encryption object code
    software classified under ECCN 5D002 is not subject to
    the EAR when the corresponding source code meets the
    criteria specified in §740.13(e), see also
    §734.3(b)(3) of the EAR.
    

    5D002  "ソフトウェア"であって、次のいずれかに
           該当するもの (規制品目リスト参照)
    
                  :
    
    注:このエントリーのもとに番号分類される暗号ソース
    コードは、たとえ EAR§734により一般に入手可能にされたと
    しても、依然として EAR の対象である。しかし、ECCN 5D002
    のもとに番号分類される一般に入手可能な暗号オブジェクト
    コードソフトウェアは、対応するソースコードが
    EAR§740.13(e)で指定される基準を満たしている場合、EARの
    対象とならない(EAR§734.3(b)(3)についても参照のこと) 。
    

    このように、カテゴリ 5 パート 2 には、前出の §740.13(e) での基準を満たすオープンソースの暗号ソフトウェアを規制の対象外とすることが明確に規定されています。したがって、Export Compliance 確認での「あなたのアプリは米国輸出管理規則のカテゴリ 5, パート 2 で規定されたいずれかの適用除外の資格がありますか?」というふたつめの質問に対する「stone for iOS」のステータスは「Yes」です。

    こうして質問への回答を終えアプリ本体のアップロードを完了しました。後は審査待ちです。

    「stone for Android」での経験とふたつのアプリケーションストア

    実のところ、ここまでのプロセスは先年 Android 用アプリとして Google Play ストアで公開した「stone for Android」でのケースとほとんど同じでした。同アプリもまたオープンソースソフトウェアです。iOS 版が OpenSSL ライブラリをスタティックリンクしているのに対し Android 版はシステムに含まれている OpenSSL の共有ライブラリをダイナミックリンクしているといった構成上の違いはあるものの、これまで見てきたように EAR は暗号処理の実体がどこにあっても暗号を使うソフトウェアを等しく「暗号ソフトウェア」として扱うため、米国から輸出を行う上でこの Android 版と今回の iOS 版に法律上の区別はありません。

    また、前述のように App Store の Export Compliance 確認画面に「あなたはあなたのプロダクトの適切な分類に責任がある」と明記されているのと同様に、Google 陣営の [ support.google.com - Android デベロッパー > ヘルプ > 販売者のためのガイドライン > 輸出法の遵守 ] のページにも「コンプライアンス要件を判断するのはあくまでもデベロッパーの責任です」と記述されており、その部分についてはこのふたつのストアの基本的なスタンスは共通していると考えてよさそうです。

    逆に、アプリケーション登録まわりでの両ストアの対応の大きな違いとしては、まず、App Store では登録時の Export Compliance の確認が複数の質問で念入りに行われその過程で対応指針の指示までもが行われるのに対し、Google Play では「当該アプリは米国の輸出法を遵守している」旨の宣言文に添えられたチェックボックスのクリックひとつで完了する(※)点と、広く知られているように App Store ではアプリの事前審査を専任のスタッフが実施しているのに対し、Google Play では自動化されたシステムが処理している(※)点のふたつが挙げられるでしょう。 (※2014年4月時点)

    いずれにせよどちらも同じ米国内に拠点を置くメジャーなアプリケーションストアであることは共通しているわけで、数年前に Android 版を何事もなくリリースした背景もあり、同様の対応を行った今回の iOS 版についてもコンプライアンス面での不安はあまり感じていませんでした。ところが・・・

    リジェクトされました (^^;

    申請から一週間ほど経ったところで App Store からメールが届きました。思惑とは裏腹にリジェクトされたようです。あれれと思いながら詳細を iTunes Connect の Resolution Center で確認しました。次の内容です。

    Reasons

    Program License Agreement

    ----- PLA 2.3 -----


    We found that your Application Description states:

    "This product includes cryptographic software..."

    However, your app does not have Export Compliance, which does not comply with the iOS Developer Program License Agreement, as required by the App Store Review Guidelines.

    Section 2.3 of the iOS Developer Program License Agreement specifies,

    "You certify that (i) none of the Licensed Applications contains, uses or supports any data encryption or cryptographic functions; or (ii) in the event that any Licensed Application contains, uses or supports any such data encryption or cryptographic functionality, You will, upon request, provide Apple with a PDF copy of Your Encryption Registration Number (ERN), or export classification ruling (CCATS) issued by the United States Commerce Department, Bureau of Industry and Security and PDF copies of appropriate authorizations from other countries that mandate import authorizations for that Licensed Application, as required."

    Please review your app's encryption ability, and when resubmitting your binary, check the appropriate answers to the questions in the Export Compliance section of iTunes Connect. You may be asked some follow-on questions to determine the level of encryption in your app; you may also be asked to provide a copy of your CCATS.

    If you have questions related to export compliance and your app's use of encryption, please contact the App Store Export Compliance team at ▒░░▒░▒░@apple.com.
    ざっくり要約すると、「アプリの説明に "This product includes cryptographic software..." とあるが、あなたのアプリには米国商務省から発行された暗号登録番号(ERN)や製品分類自動追跡システム番号(CCATS)の PDF コピーが添えられておらず iOS Developer Program License Agreement に準拠していない。コンプライアンスの不備である。Export Compliance の質問へ適切に回答し対処して下さい」という内容です。しばらく考えました。

    そもそも App Store の拠点は米国内にあり、それ故にそこから製品を配布したければ米国法に従う必要があるわけですが、その一方で当の App Store 自体もまた米国法に準じての運営が前提であるはずです。これまでの話題の通り、EAR は決して「暗号ソフトウェア」のすべてを規制しているわけではありません。したがって、「cryptographic software」の文言が説明文に含まれているからと言ってそのアプリが常に「カテゴリ 5 パート 2」での規制対象に該当するとみなすことは法に照らして適切とは言えませんし、ただちに ERN や CCATS の提示ばかりを求めるのもいささか短絡的でしょう。この指摘にはそういう違和感がありました。

    なお、"This product includes cryptographic software..." のくだりは OpenSSL ライセンス中のテキストです。あらためて同ライセンスを確認してみると、こういった場所への記載を求められているのは "This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit. (http://www.openssl.org/)" の一文のみのようです。そのためこのテキストを外すことは簡単ですが、仮にその即物的な編集を行った結果審査を通ったとしてもそれは本質的な解決ではありません。暗号処理を使いながらも当該ソフトウェアが法規制の対象外となる条件を満たしていることを確認ずみである以上、本来であれば暗号を使っていることを真正面から記述しても何の問題もないはずです。

    先方からの連絡には「the App Store Export Compliance team」の紹介もありますが、ここはまず自分の頭で考えることにしました。今回の件の輪郭をなぞると、ストア側がアプリの審査において Export Compliance 確認への回答内容よりも「アプリの説明」の内容に注目した構図が浮き上がります。ということは、その状況に対して効果のあるアプローチは、「アプリの説明」においてこのアプリが米国法に適切に対応していることを積極的に主張することではないかと考えました。

    説明文の変更〜再申請

    そんなわけで、アプリケーションの説明文に「このソフトウェアは暗号を使っているが米国輸出法上の "規制されない技術及びソフトウェア" に該当するものであり法に従って配布している」という旨を明記することにしました。

    当初その文章をゼロから作りかけていたのですが、ふと、法や規則への言及を含むこういった文章にはたいてい定型的なパターンがあることを思い出しました。身近なところではソフトウェアの使用許諾契約書の文章などがそうですね。この手の定型的な文章には多くの場合先人の知恵と経験知が凝縮されていることに加え表現そのものに対する社会的な認知度が概して高い側面があるため適切に使えばあえて自分ではじめから作文をするよりも効果的でしょう。

    ネットを探索し、所定のソフトウェアが TSU であることを表明する目的のために The Apache Software Foundation をはじめ複数のソフトウェアベンダが使用している定型的な表現の存在を知りました。その内容を確認して手を加え、最終的に説明文を次の内容に変更して再び App Store への登録申請を行いました。なお、Export Compliance の確認には迷いなく初回と同様に「Yes - Yes」と回答しました。

    (「* Cryptographic Software Notice *」以降の参考訳)

    * 暗号ソフトウェアに関する注意 *

    このソフトウェアは暗号ソフトウェアを含んでいます。あなたが現在住んでいる国は暗号ソフトウェアの輸入、所持、使用、および/または他国への再輸出 に制限があるかもしれません。いかなる暗号ソフトウェアも使用する前に、許可されているかどうかを確かめるために、暗号ソフトウェアの 輸入、所持、または使用、そして再輸出に関するあなたの国の法律、規則および政策をご確認下さい。詳細は http://www.wassenaar.org/ を参照して下さい。

    アメリカ合衆国商務省産業安全保障局 (BIS) はこのソフトウェアを、非対称アルゴリズムで暗号機能を使用または実行する情報セキュリティソフトウェア を含む 輸出規制分類番号 (ECCN) 5D002.C.1 として分類しました。この配布の形式と作法は オブジェクトコードとソースコードの両方に許可例外 Technology Software Unrestricted (TSU) のもとでの輸出のための資格を与えています。(BIS 輸出管理規則のセクション 740.13 を参照して下さい)

    このソフトウェアは無料で配布され 全体のソースコードは以下のように一般に入手可能です:
    - stone for iOS (オリジナルの 'stone' を含む)
    https://github.com/mkttanabe/stone-for-iOS
    - OpenSSL
    https://www.openssl.org/source/

    ※「ECCN 5D002.C.1」の詳細は前回の記事の「『ECCN 5D002』とは」の項を参照して下さい

    結果と所感

    後日再びストア側のレビューが実施され、今回は何事もなく審査を通過し App Store へ「stone for iOS」が掲載されました。リジェクト時にストア側から連絡された内容には従わなかったものの結局こちらの判断は間違っていなかったようです。App Store へ暗号を使うアプリを登録したのはこれが初めてでしたが、今回のような経緯でリジェクトされたことは良い経験でした。前述のとおり同じアプリであるにも関わらず Google Play ストアの場合にはコンプライアンスまわりが自己申告のみで通ったことを考え合わせると両ストアの個性の違いが興味深く感じられます。

    このふたつのアプリケーションストアに閉じた話題に留まらず、今回の経験からあらためて教訓にしたいと思ったのは、何かを公にする場合にはその行為やその内容が所定のルールを適切に遵守しているという正当性を積極的にアピールすべきだということです。そういった表示や宣言を行うことがルールで義務づけられている場合にはもちろんのこと、そうでない場合や事情が判然としない場合にも、殊に文化・習慣の土壌の異なる環境においては何らかの形で主体的に表明を行う判断が安全でしょう。App Store にしても今回のように暗号を使用しながらも規制対象外であるアプリの申請方法を明示しているわけではありませんが、そのことが「説明を必要としない」という意味ではなかったことはここに書いた通りです。ひとことで言えば、ルールを適切に守ることと同様に、それを適切に伝えることもまた自分にとっても他者にとっても重要だということに他ならないと思います。つまるところルールを間に挟んだ両側にいるのはどちらも「人間」なのですよね。


    二度に渡り記事の前半では主に米国輸出管理規則の概要と読み方や扱い方、後半では実在のスマホアプリへ許可例外を適用し App Store で公開するまでの具体的な話題を中心に紹介しました。TSU 以外の様々な許可例外を適用するケースや米国商務省への申請が必要となるケースなど実務上のバリエーションは多岐に渡りますが、まずはこの記事が EAR と米国のアプリケーションストアに向き合いながら対応を行う上での参考情報のひとつとなれば何より幸いです。

    前回の記事へ

    (tanabe)
    klab_gijutsu2 at 08:30|この記事のURLComments(0)TrackBack(0)
    2014年04月09日

    スマホアプリと米国輸出規制に関するメモ

    はてなブックマークに登録

    ※2014-04-21 追記:続きの記事を公開しました
    「スマホアプリと米国輸出規制に関するメモの続き 」へ

    記事の概要

    スマートフォンの普及に伴いそれをターゲットとするソフトウェアベンダや開発者が増えています。スマートフォン向けのアプリケーションを公開する際には通常 Google Play や App Store 等のアプリケーションストアを使用しますが、これらのストアの拠点は米国にあるためそこでアプリを配布することは米国からの輸出に該当し 同国の輸出法の適用対象となることに注意が必要です。

    米国の輸出規制の内容は EAR (Export Administration Regulations 輸出管理規則) に定められており、規制に該当するか否かは「何」を「どこへ」輸出しそれを「誰」が「何のため」に使用するかによって決まります。規制対象にあたる輸出を行う際には米国商務省へ申請を行い有効期限を伴う 輸出許可(Export License)を得なければならないケースもありますが、EAR にはある品目の輸出が規制の対象外となるケースや 所定の条件のもとに許可不要となる 許可例外(License Exception)について個別の規定があり、実際には案件の多くにそのいずれかを適用できるようになっています。ただし、その判断と対応を適切に行うためには関連する規則を適切に読み解くことが必要となります。

    ソフトウェアに関しては、EAR は「一般に入手可能な技術及びソフトウェア」を輸出規制の対象外とする一方で 暗号処理を包含または使用するソフトウェア(「暗号ソフトウェア」)については規制対象として詳細な規則を設けています。暗号品目に関する規制内容は EAR の「商務省規制品リスト」の中の「カテゴリ 5 パート 2」同参考訳)に定められています。

    先日、手元で開発した iOS 用のアプリ「stone for iOS」を App Store で公開しました。このアプリは OpenSSL ライブラリをスタティックリンクしそこに含まれる暗号関数を使用しています。そのため EAR の規制対象にあたりますが、許可例外のひとつである「規制されない技術及びソフトウェア」(=「TSU」)に該当する要件をあらかじめ備えていたため米国からの輸出に際し要した手続きはわずかなものでした。そういった具体的な事例の情報はあまり見かけないため、この記事では関連する規則の説明とともに今回のケースにおいて手元で行った対応内容を紹介します。筆者は取り立てて法律に詳しいわけではなく単に自分の手がけたソフトウェアを公開するために必要な実務上の手続きの一環として所定の規則を読みかじっているに過ぎませんが、情報のひとつとして、特にオープンソースソフトウェア開発者の方のご参考となれば幸いです。

    なお、EAR は暗号処理を本体に含むか否かにかかわらず暗号を使用する品目全般を対象としています。ソフトウェアの場合だと OS 組み込みの暗号機能のみを使っているソフトウェアも原則的に規制の対象となります。記事ではその話題にも触れてみることにします。

    資料

    米国輸出規制に関する資料へのリンクです。 続きを読む

    klab_gijutsu2 at 10:09|この記事のURLComments(0)TrackBack(0)
    2013年05月28日

    GCM で Wake On WAN

    はてなブックマークに登録

    ソーシャルな用途ばかりではなく GCM (Google Cloud Messaging for Android) は自分が自分の端末へ通知を送るために使うのにも便利です。その応用例として、以前このブログで 遊んでいる端末を遠隔操作可能な監視カメラとして留守宅で使うアイディア を紹介しました。 そこでは Android 端末を "ファイアウォール越しに外からのプッシュ通知を受信できる消費電力の小さなコンピュータ" として利用したわけですが、今回個人的な必要からそれと同じ考え方で別のアイディアを形にしてみました。GCM 経由で端末へ指示を送り所定の PC を Wake On LAN させるというものです。

    Android 端末を一台 LAN に接続した状態で待機させておけば、Wake On LAN の設定とアプリへの登録を済ませた任意の PC をルータの設定に手を加えることなく外から起動することができます。起動さえできれば PC の遠隔操作そのものには TeamViewer など既存のソリューションを柔軟に利用できますね。

    このところ出先から自宅 PC へのアクセスが必要となるケースが増えていたもののルータに穴を開けるのはあまり気が進まずやむなく PC を起ち上げっぱなしにしたりしていたのですが、不経済な上にこれからの夏場には密室での連続稼動に一抹の不安がありました。興味のある方はお試し下さい。

    アプリ本体: Google Play - WakeOnLan GCM

    ソースコード: GitHub - WakeOnLanGCM

    使い方

    以下に使い方を簡単に控えます

    対象 PC をアプリへ登録

    • 「WakeOnLan GCM」のインストール後、Wake On LAN 設定済みの対象 PC の情報をアプリに登録する
    • エントリ名には重複のない任意の名前を指定する
    • エントリのタップでマジックパケットを送出、長押しすると編集・削除へ

    端末を GCM へ登録〜遠隔操作用 HTML フォームの生成と使用

    • 対象 PC の登録を終えたらアプリケーションメニューから「GCM 登録画面」へ移動
    • 端末を GCM へ登録するとパスワード設定を経て遠隔操作用 HTML フォームとそれを添付した自分あての新規メールが生成される
    • このフォームにはアプリに登録した PC の「エントリ名」のチェックボックスが並ぶ
    • ネットへ接続した環境でブラウザへこのフォームをロード。ボックスを適宜チェックしパスワードを投入してサブミット〜GCM 経由で LAN 上の Android 端末がメッセージを受信しマジックパケットを送出する


    (tanabe)
    klab_gijutsu2 at 19:47|この記事のURLComments(5)
    2013年05月02日

    QR コードを機器間での秘密情報の輸送に利用する試み

    はてなブックマークに登録

    QR コードのもうひとつの利点

    QR コードはインターネットアドレスなど所定のテキストデータを簡単に機器に取り込むための手段として広く利用されています。印刷物に限らずさまざまな媒体で扱えるのも便利ですね。

    先日、公開鍵暗号を使う Android アプリを試作していた折に、自分の端末 A で生成した秘密鍵を手持ちの別の端末 B へ安全に輸送する手段の検討が必要になりました。やり方はいろいろありそうですが、よりシンプルな方法をとあれこれ考えている内に、机上に無造作に置いたチラシの QR コードにふと目が留まりました。

    QR コードからのデータ取り込みは通常光学的に行われます。このことは電子機器の視覚を利用したデータ通信と考えることができるでしょう。第三者による情報窃取の可能性がしばしば問題となるネットワーク通信とは異なり、「目」を使っての情報伝達には物理的に介入することが難しいため、デジタルデータを画像で表現する QR コードは秘匿性の求められる局面での通信手段としても有用と考えられます。

    今回の話題はふたつの端末の間でのデータ授受なので、送り側の端末で QR コードの生成と画面表示を行い、受け側の端末でそれを読み取ればよさそうです。情報の受け渡しさえできればコードを残しておく必要はないので QR コードは常に動的に生成し使い捨てとすればよいでしょう。

    留意すべき点

    一件の QR コードに格納できるデータの量には上限があります。QR コードの開発元である株式会社デンソーウェーブ様のサイトの記事から概要を示す表を以下に引用します。

    QRコード規格化・標準化|QRコードドットコム|株式会社デンソーウェーブ

    コードの大きさ 21セル×21セル〜177セル×177セル(4セル /辺毎に増加)
    情報の種類及び 情報量(混在も可)
    QRコードの情報量とバージョンについて
    数字
    英数字
    8ビットバイト(バイナリ)
    漢字
    最大7,089文字
    最大4,296文字
    最大2,953文字
    最大1,817文字
    誤り訂正能力
    (データ復元機能)
    誤り訂正能力について
    レベルL
    レベルM
    レベルQ
    レベルH
    コードワードの約 7% が復元可能
    コードワードの約 15% が復元可能
    コードワードの約 25% が復元可能
    コードワードの約 30% が復元可能
    コード連結機能 最大16分割 (細長いエリアなどへの印刷)

    表には、セル(QR コードを構成する最小の点)構成の最大値である 177 セル×177 セルから成る「バージョン 40」の器に、誤り訂正用の冗長データの最も薄い「レベル L」でエンコードしたデータを格納する際の最大文字数が記載されています。すべての半角英数記号を使う場合は 2953 文字です。この容量の多寡はさておき、普段目にする QR コードのそれとはかけ離れた規模のデータを納めた場合、実用的にはどうなのでしょう。

    下の QR コードには半角英数記号 1675 文字を格納しています。これを一般的なサイズ・解像度の PC のモニタで表示した状態であればおそらく多くの携帯端末の QR コードリーダーで読み取ることが可能でしょう。しかし、スマートフォンの小さな画面にこれを表示した状態だと読み取りに失敗するリーダーが出てくるかもしれません。また、一般に情報の密度が高くなるほど読み取りにより多くの時間がかかるため、たとえ成功しても別のストレスが残る可能性もあります。

    そういった事情を考え合わせると、今回のような目的で QR コードを利用する場合は、一件のコードに強引に多くのデータを押し込むのではなく、複数のコードに分けて利用することを前提に余裕を持ってデータを扱うほうが賢明でしょう。

    実は上の表にも記述があるように、QR コードには一件のデータを最大 16 件のコードに分割して格納することの可能な「コード連結」という仕様があります。次の記事には分割 QR コードの実例が掲載されています。 「IT4206,QRコードの連結機能に対応していますか。」 - 株式会社エイポック様 公式サイトより -

    残念ながらすべてのリーダー・ライブラリがこの機能に対応しているわけではなく、現在手元で使っているメジャーな ZXing のライブラリも未対応のようです。しかし、今回はリーダーだけではなくライターも自作することが前提なので、両者間で整合性のとれる内容で独自の分割プロトコルを用意すれば事足りると判断しました。また、二台の端末を操作しながら複数のコードを順番に処理していくのは想像するだけで非常に面倒なので、両者の連携にネットワーク通信を併用し、リーダーが正しくコードを読み取ったらライターが自動的に次のコードを表示することにしました。

    ちなみに、先日の記事で操作性の良い QR コードリーダーを自作するために行った取り組みを紹介しましたが、そのきっかけは今回の一連の話題にありました。リーダーを自分で実装すれば上記のような細かい取り回しも柔軟に実現できるわけですね。QR コードの読み取りが主目的ではないアプリへ補助機能としてリーダー処理を組み込むことにはちょっと新鮮な印象があります。

    試作と実験

    そんなわけで次のような QR コードライターアプリと QR コードリーダーアプリを作ってみました。

    • ライターとリーダーは QR コードの出入力処理とネットワーク通信の併用によって連携する
    • リーダーは起動時に UDP ブロードキャストにより LAN 上のライターを探索、ライターはこれに呼応する
    • ライターはダミーの RSA 秘密鍵 PEM データを 200 文字ごとに分割しそれを格納した QR コードを順次生成・表示する
    • QR コードリーダーはライターの表示するコードをカメラ経由で順次読み取る
    • ライターは生成するコードに毎回連番情報とランダム文字列を挿入、読み取りを終えたリーダーがそれに一致する文字列をネットワーク通信でライターへ返した場合のみライターは次のコードを生成・表示する
    • リーダーは最後のコードの読み取りを終えると受信した秘密鍵情報を表示する

    デモ動画

    動作の様子です。奥の端末で QR コードライター、手前の端末でリーダーを動かしています。

    リソース

    今回試作したソフトウェア一式を公開します。興味のある方はお試し下さい。


    QR コードライター・リーダーのソースコード
    MyQRCodeReaderEx - github
    QR コードライターのビルドずみ apk

    http://dsas.blog.klab.org/data/qr_secret/QR_writer.apk

    QR コードリーダーのビルドずみ apk

    http://dsas.blog.klab.org/data/qr_secret/MyQRCodeReaderEx.apk


    (tanabe)
    klab_gijutsu2 at 14:25|この記事のURLComments(0)TrackBack(0)
    2013年04月15日

    「ZXing QR コードスキャナー」の内部処理を追う

    はてなブックマークに登録

    ZXing ("Zebra Crossing") Team による「QR コードスキャナー (以下、"ZXing スキャナ"と略記) は操作性が良く人気の高い定番 Android アプリのひとつです。同アプリでは ZXing Team 自らが開発を継続しているオープンソースのバーコード処理用ライブラリが使用されています。
    zxing Multi-format 1D/2D barcode image processing library with clients for Android, Java
    このライブラリの優秀さはスマートフォンでバーコード/QR コードを処理する際の実質標準の座にある状況からも裏打ちされていますが、ZXing スキャナの「使いやすさ」は本ライブラリの性能のみに依るものではなくアプリの実装に大きく支えられています。ZXing スキャナのソースコードは公開されているので、それに学べば本家と同等の使いやすさを備えた QR コードリーダーの自作が可能となるはずですね。操作性の良いリーダーを自作できるのであれば QR コード読み取り機能をアプリへ組み込む際にわざわざ ZXing スキャナをインテントで呼び出す必要はなく、独自の処理を柔軟に組み込むこともできるでしょう。また、上記ライブラリを知り尽くした ZXing 謹製スキャナのコードはライブラリの性能を最大限に引き出すための最良のお手本となりそうです。

    ZXing スキャナのソースコードを正面から分析した情報はほとんど見当たらないようですが、手元での調査を通じて得られた情報と、そのエッセンスを小さくまとめた形で試作した自作リーダーのリソース一式を公開します。なお、この記事では ZXing 2.1 Release 中の zxing-2.1/android/ 配下のソースコードを対象としています。

    注目すべき要素

    なぜ ZXing スキャナは QR コードリーダーとして使いやすいのでしょう?その要素こそが実装を調べる上で注目すべきポイントとなるでしょう。特長をみっつピックアップしてみます。

    • コードの認識が早い
      ターゲットにレンズを向けてじっとしていればすぐに認識が完了する
    • 対象とするコードをファインダ枠内に留める以外の操作が不要
      フォーカスを合わせたり画面をタップするといった操作をする必要がない
    • コード識別パターン検出位置を示すポイントが描画される
      プレビュー表示にコード識別パターンを発見した位置を示すポイント (ResultPoint) がリアルタイムで描画されるため端末の位置・角度を加減し易い
    ResultPoint の描画

    ソースコードの追跡とまとめ

    コード読みの途中で迷子にならないための道具としてホワイトボード代わりにエクセルシートを使いました。あくまでも作業用なので決して見た目の良いものではありませんが記録としてそのまま掲載します。

       コードリーディング時のメモ

    以下、ソースコードから得られた情報を整理してみます。

    ※クリックすると大きな図が開きます

    • プレビュー表示中に裏側で走っている太い処理の内容
      ※カメラには 2 秒ごとに autoFocus() が適用される
      1. 現在プレビュー表示中のフレームイメージを取得
      2. 取得したイメージをバックグラウンドスレッドへ渡す
      3. バックグラウンドスレッドはイメージを ZXing ライブラリ処理に渡す
      4. ライブラリはコードの検出過程で QR コード画像の識別パターンである可能性のある箇所(ResultPoint)を見つけるとその座標を UI 側へ逐次通知する
      5. ResultPoint を受け取とった UI 側はそれを表示中のプレビュー画面に重ねて描画する
      6. ライブラリ側処理が今回のフレームイメージから QR コードを検出しなかった場合、1. からの処理が繰り返される
      つまり、ZXing スキャナ は QR コードを見つけるまで絶えずフレームイメージの取得とその画像分析を繰り返している

    • 重要なクラスとその処理の概要
      - com.google.zxing.client.android
      • CaptureActivity
        QR コードスキャナーの Activity クラス
      • CaptureActivityHandler
        CaptureActivity のハンドラ。DecodeThread インスタンスを生成し DecodeHandler からライブラリ処理による QR コード認識結果を受け取る。コードが認識されなかった場合はこのクラスが CameraManager.requestPreviewFrame() を叩くことで、次のフレームイメージ取得〜QR コード認識という全体のループを継続させる
      • DecodeThread
        プレビュー中のフレームイメージから QR コードを検出するための手続きを連続的に繰り返すバックグラウンドスレッド
      • DecodeHandler
        DecodeThread のハンドラ。ZXing ライブラリの MultiFormatReader クラスの QR コード検出メソッドを呼び、認識結果を CaptureActivityHandler へ伝える
      • ViewfinderView
        View の継承クラス。プレビュー画面の描画、ファインダ矩形上に捕捉したイメージについての識別パターン検出点(ResultPoint)の描画を onDraw() メソッド内で実施
      • ViewfinderResultPointCallback
        ZXing ライブラリ内に宣言のある「com.google.zxing.ResultPointCallback」インターフェイスの実装クラス。DecodeThread 経由でライブラリ側処理へ登録され、ライブラリが ResultPoint 認識時にこのクラスの foundPossibleResultPoint() メソッドをコールバックする。同メソッドは ViewfinderView クラスへ ResultPoint を伝達する
      - com.google.zxing.client.android.camera
      • CameraManager
        カメラのオープン・クローズや PreviewCallback へのフレームイメージ取得指示など
      • PreviewCallback
        Camera.PreviewCallback の実装クラス。onPreviewFrame() 発生時に DecodeHandler へキャプチャイメージを渡す
      • AutoFocusManager
        バックグラウンドで 2 秒ごとにカメラに autoFocus() を適用
      • CameraConfigurationManager
        カメラまわりの解像度や各種パラメータの取得・設定

    • 使いやすさを支えているもの
      • 「コードの認識が早い」
        → プレビュー表示中、コードを検出するまで絶えずフレームイメージの取得とバックグラウンドスレッドでの分析を繰り返している
      • 「対象とするコードをファインダ枠内に留める以外の操作が不要」
        → カメラへのオートフォーカスの適用を効果的に利用している
      • 「コード識別パターン検出位置を示すポイントが描画される」
        → 検出位置を直ちに通知するためにライブラリの深部に用意されたコールバック機構と UI への描画処理を適切に組み合わせて使用している

    リーダーの試作

    以上のように、全体像が見えてしまえば ZXing スキャナの操作性を支えているのは意外なほどシンプルなしくみであることがわかります。それを自作のコードに組み込むことは難しくなさそうですね。そこで ZXing スキャナの実装に倣いつつできるだけ短いコードでざっくりとリーダーを作ってみることにしました。認識結果はダイアログ表示のみとしています。動作確認は一部の環境でのみ行っており、不具合があれば適宜手を入れて下さい。

    MyQRCodeReader - github

    ソースコードの一覧と概要を以下に示します。

    • MyActivity.java
      本アプリの Activity クラス。implements SurfaceHolder.Callback, Handler.Callback, Camera.PreviewCallback
    • MyCameraConfigurationManager.java
      ZXing スキャナの CameraConfigurationManager より。カメラパラメータの設定を決め打ちに
    • MyDecodeThread.java
      ZXing スキャナの DecodeThreadより。対応コードフォーマットを決め打ちに
    • MyDecodeHandler.java
      ZXing スキャナの DecodeHandlerより。コード認識成功時に MyActivity へ Bitmap は送らず検出したテキストの情報のみに
    • MyFinderView.java
      ZXing スキャナの ViewfinderView および ViewfinderResultPointCallback より。オリジナルの ViewfinderView はプレビュー画面全体を覆っているが単純化のためここではファインダ矩形と一対一に変更。ResultPoint が 4 以上になるとファインダ内に「Warning!」と表示
    試作リーダーの動作の様子

    ビルドずみの apk はここにあります

    http://dsas.blog.klab.org/data/zxing_qr_scanner/MyQRCodeReader.apk


    (tanabe)
    klab_gijutsu2 at 17:00|この記事のURLComments(0)
    2012年12月11日

    Android の GCM をプライベートな目的に使う

    はてなブックマークに登録

    GCM (Google Cloud Messaging) は所定の端末へメッセージを送るための有用なしくみですが、何も一斉通知やソーシャルな用途ばかりではなくもちろんきわめて個人的な目的のためにも利用できます。今回はその方面でのアイディアをひとつざっくり形にしてみた例を紹介します。いろいろ応用もできるでしょう。

    自分の端末を遠隔操作

    Android 界隈は依然にぎやかで次々に新しい製品が発売されています。そのため複数の端末を持っている人も少なくないでしょう。まだまだ使える端末を遊ばせておくのはもったいないので、これを外出中の自宅の監視カメラとして使うことにしました。 端末を室内の対象物に向けて固定しておき、出先や仕事場から GCM 経由で端末へメッセージを送出、それをトリガーにアプリが撮影したスナップを Dropbox 経由で確認します。

    シンプルな実装の割に結構役に立っています。 アプリ本体とソースコードを以下で公開しています。

    RemoteWand - Google Play
    RemoteWand - github

    処理の流れ

    端末の登録

    遠隔操作の対象とする端末上で「RemoteWand」を起動して「登録」ボタンを押下すると GCM サーバへ端末が登録されトリガー発信用のフォームを含む HTML ファイルの添付された自分あてのメールが生成されます。

    トリガーの送出〜撮影

    PC やスマホのウェブブラウザに上記のフォームをロードし登録時に指定したパスワードを添えてサブミットするとアプリケーションサーバから GCM サーバへ要求が送出され GCM サーバが当該端末へ所定のメッセージをプッシュします。端末にインストールずみの RemoteWand はこのメッセージをトリガーにカメラで静止画の撮影を行います。Dropbox の「カメラアップロード」機能は自動的に撮影画像のアップロードを行うためあらかじめ端末にインストールしておけばリモートで簡単に写真を確認できます。


    (tanabe)

    klab_gijutsu2 at 15:39|この記事のURLComments(2)TrackBack(0)
    2012年09月21日

    Android のプッシュ通知用コネクションに関するメモ(補記)

    はてなブックマークに登録

    前回の記事では、Android のプッシュ通知機構 (GCM) が 「Google 所管のサーバと Android 端末との間の持続接続」 をどのように使用しているかを調べてみました。
    DSAS 開発者の部屋 : 「Android のプッシュ通知用コネクションに関するメモ」
    この記事の文中に、上記の持続接続が「プッシュ通知以外の用途にも利用されている様子でありそちらもおって調査したい」 と注釈を添えています。今回はその内容と関連する話題を控えます。

    まとめ

    端末と mtalk.google.com:5228 との間の接続はプッシュ通知以外に次の用途で使用される

    • Android 版 Google Talk クライアントのメッセージ送受信 (ボイスチャットを除く)
    • Google Play アプリのウェブページから端末へのプッシュインストール指示

    - 他にもあるかも?
    - 非公開の内部仕様につき今後変更される可能性も

    Google Talk について

    • Android 版 Google Talk クライアントは、com.google.process.gapps (GoogleServicesFramework) プロセスの管理する 「mtalk.google.com:5228」 とのコネクションを使用する。このコネクションを使用できない場合はエラーとなり、独自に代替ポートが使用されることはない

    • ちなみに PC 版 Google Talk クライアント (googletalk.exe) はメッセージ送受信に 「talk.google.com:5222」 とのコネクションを使用する。5222 番ポートを使用できない場合は代わりに 「talk.google.com:443」 を使用する

    • 「mtalk.google.com」 には 「mobile-gtalk.l.google.com」 という別名がある。ネーミングから想像すると、このホストとのコネクションは本来モバイル端末において Google Talk からの利用を主目的とするものであり、プッシュ通知機構からの利用はあるいはむしろ副次的・応用的なものだったのかも?
      $ dig mtalk.google.com
      
      ; <<>> DiG 9.2.1 <<>> mtalk.google.com
                               :
      ;; ANSWER SECTION:
      mtalk.google.com.       81547   IN      CNAME   mobile-gtalk.l.google.com.
      mobile-gtalk.l.google.com. 291  IN      A       74.125.31.188
                               :
      

    Google Play ページからのプッシュインストールについて

        

    • Google Play アプリケーションページからのインストール指示は mtalk.google.com:5228 コネクション経由で端末へ送出されパッケージマネージャへ渡される

    • このプッシュインストールは感覚的には GCM によるプッシュ通知とあまり変わらないが、GCM の端末要件が Android 2.2 以上であるのに対し、Play ストアページからのプッシュインストールは 2.2 未満のバージョンでも機能するという違いがある

    • Android 1.6 環境を確認したところ、システムブート後に mtalk.google.com:5228 コネクションが自動的に確立する点は共通しているが、違いとして目に付くところでは 2.2 以降での com.google.process.gapps プロセスの実体である "/system/app/GoogleServicesFramework.apk" が存在しない。中核となる持続接続のハンドリング自体は旧バージョンにおいても実装されているわけで、C2DM, GCM の要件が 2.2 以上とされた背景にはおそらくはモジュール設計上の複合的な要因があるものと察せられる

    mtalk.google.com:5228 に接続できない場合は?

    端末上の com.google.process.gapps プロセスが 「mtalk.google.com:5228」 へコネクションを確立できない場合の所作を確認した

    • 実験環境のルータの設定で、「mtalk.google.com」 の既知の分散先サーバである 「173.194.72.188」 「74.125.31.188」 の 5228 番ポートへの LAN 上の各ノードからのアクセスを遮断。この状態で Android 端末を起動し観察を行った

    • 端末から mtalk.google.com:5228 へ接続不可につき connect タイムアウトまでステートは SYN_SENT のまま変化せず。タイムアウト後は何度かリトライが発生。対象は常に 5228 番ポート
      Active Internet connections (w/o servers)
      Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
      tcp        0      0 10.10.0.10:43695        173.194.35.15:80        TIME_WAIT   -
      tcp        0      0 ::ffff:10.10.0.10:38835 ::ffff:74.125.235.130:443 ESTABLISHED 776/com.android.ven
      tcp        0      0 ::ffff:10.10.0.10:46503 ::ffff:74.125.235.185:80 ESTABLISHED 881/berserker.andro
      tcp        0      1 ::ffff:10.10.0.10:34224 ::ffff:173.194.72.188:5228 SYN_SENT    256/com.google.proc
                              :
      
                         (時間の経過・・・)
      Active Internet connections (w/o servers)
      Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
      tcp        0      0 10.10.0.10:43695        173.194.35.15:80        TIME_WAIT   -
      tcp        0      0 ::ffff:10.10.0.10:38835 ::ffff:74.125.235.130:443 ESTABLISHED 776/com.android.ven
      tcp        0      0 ::ffff:10.10.0.10:46503 ::ffff:74.125.235.185:80 ESTABLISHED 881/berserker.andro
                              :
      
                         (時間の経過・・・)
      Active Internet connections (w/o servers)
      Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
      tcp        0      0 10.10.0.10:43695        173.194.35.15:80        TIME_WAIT   -
      tcp        0      0 ::ffff:10.10.0.10:38835 ::ffff:74.125.235.130:443 ESTABLISHED 776/com.android.ven
      tcp        0      0 ::ffff:10.10.0.10:46503 ::ffff:74.125.235.185:80 ESTABLISHED 881/berserker.andro
      tcp        0      1 ::ffff:10.10.0.10:32648 ::ffff:173.194.72.188:5228 SYN_SENT    256/com.google.proc
                              :
      

    • 開発者向けの公式ページ に以下の記述が見られるためリトライ時に 5229 or 5230 番ポートが対象となることに期待したが今回の手順による観察ではその所作は見られなかった
       Note: If your organization has a firewall that restricts the traffic
       to or from the Internet, you'll need to configure it to allow
       connectivity with GCM. The ports to open are: 5228, 5229, and 5230. 
       GCM typically only uses 5228, but it sometimes uses 5229 and 5230.

    • 以上の結果から Android 端末の参加するネットワークでは必ず外向きの 5228 ポートへのアクセスを許可しなければならないことがわかる

    • この調査の過程で、「mtalk.google.com」 の分散先として、前出の 2 サーバに加え 「173.194.79.188」 の存在を確認した (2012 年 8 月末時点)

    付録:端末上で拾ってみたパケットの様子

    Google Talk     

    プッシュインストール     


    (tanabe)
    klab_gijutsu2 at 13:36|この記事のURLComments(3)TrackBack(0)
    2012年08月07日

    Android のプッシュ通知用コネクションに関するメモ

    はてなブックマークに登録

    Android のプッシュ通知機構(GCM, 旧 C2DM)は有用なしくみですが、オープンソースではないソフトウェア要素が関わっているためか内部仕様に近い情報をあまり見かけないのが残念です。手元での観察結果をもとにプッシュ通知で使用されるネットワークコネクションまわりの情報をいくつかまとめてみました。

    まとめ

    • Android 端末上の com.google.process.gapps プロセス は mtalk.google.com:5228 へ TCP コネクション [A] を張る
      (通常は 5228 番ポートだが 5229, 5230 番ポートが使用される場合もある)
    • com.google.process.gapps プロセスは基本的に [A] をずっと張りっ放しにしており接続維持のため無応酬 15分ごとに Keep-Alive パケットを流す
    • GCM, C2DM のプッシュ通知はいずれも [A] 経由で端末へ送られる

    ※[A] はプッシュ通知以外の用途にも利用されている様子でありそちらもおって調査したい

    端末− Google サーバ間のコネクションについて

    コネクションの存在確認
    GCM に関する開発者向け情報へアクセスすると次の記述が見つかります。

    [GCM Architectural Overview | Android Developers] より

    Note: If your organization has a firewall that restricts the traffic to or from the Internet, you'll need to configure it to allow connectivity with GCM. The ports to open are: 5228, 5229, and 5230. GCM typically only uses 5228, but it sometimes uses 5229 and 5230.
    GCM では端末と所定の外部サーバとの接続に通常 5228 番ポートを使うということですね。 さっそく Android 端末実機上で netstat コマンドを実行してみます。

    ※ここで使用しているのは root 化ずみの実験用端末であり、
    各コマンドは Android オリジナルのものではなくBusyBox 版を利用しています。
    

    pid=3547 のプロセスが 「173.194.72.188:5228」との間に TCP コネクションを張っていることがわかります。

    # netstat -p
    Active Internet connections (w/o servers)
    Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
    tcp        0      0 10.10.0.10:60272        74.125.235.111:80       ESTABLISHED 6337/berserker.andr
    tcp        1      0 ::ffff:10.10.0.10:33375 ::ffff:74.125.235.145:443 CLOSE_WAIT  3547/com.google.pro
    tcp        0      0 ::ffff:10.10.0.10:22    ::ffff:10.10.0.6:3667   ESTABLISHED 6385/dropbear
    tcp        1      0 ::ffff:10.10.0.10:52412 ::ffff:74.125.235.180:443 CLOSE_WAIT  7318/com.google.and
    tcp        0      0 ::ffff:10.10.0.10:22    ::ffff:10.10.0.6:3911   ESTABLISHED 6879/dropbear
    tcp        0      0 ::ffff:10.10.0.10:44984 ::ffff:173.194.72.188:5228 ESTABLISHED 3547/com.google.pro
    tcp        0      0 ::ffff:10.10.0.10:36091 ::ffff:74.125.235.122:80 ESTABLISHED 6337/berserker.andr
    Active UNIX domain sockets (w/o servers)
    Proto RefCnt Flags       Type       State         I-Node PID/Program name    Path
    unix  2      [ ]         DGRAM                     10725 3445/system_server  /data/misc/wifi/sockets/wpa_ctrl_3445-307
    unix  2      [ ]         DGRAM                     10727 3445/system_server  /data/misc/wifi/sockets/wpa_ctrl_3445-308
    unix  2      [ ]         STREAM                     1293 103/rild            /dev/socket/rild-debug
    unix  2      [ ]         STREAM                     1295 103/rild            /dev/socket/rild
    unix  4      [ ]         DGRAM                     10722 3762/wpa_supplicant /dev/socket/wpa_wlan0
    unix  3      [ ]         STREAM     CONNECTED      17159 108/adbd
    unix  3      [ ]         STREAM     CONNECTED      17158 108/adbd
                               :
    

    当該コネクションを保持するプロセスについて
    pid=3547 は「com.google.process.gapps」というプロセス。
    # ps | grep 3547
      3547 app_1      0:05 com.google.process.gapps
    

    「com.google.process.gapps」は「Google Services Framework」のプロセス名。
    (関連記事)
    "Fix for Google services Framework (process com.google.process.gapps) has stopped unexpectedly"

    「Google services Framework」の実体は以下のシステムパッケージ。(※ソース未公開)

     # ls -l /system/app/GoogleServicesFramework.apk
    -rw-r--r--    1 root     root       2867063 Feb  6  2012 /system/app/GoogleServicesFramework.apk
    

    接続先サーバについて
    「173.194.72.188」に該当するホスト名は「mtalk.google.com」。
    $ host -v mtalk.google.com
    Trying "mtalk.google.com"
    ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 30177
    ;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
    
    ;; QUESTION SECTION:
    ;mtalk.google.com.              IN      A
    
    ;; ANSWER SECTION:
    mtalk.google.com.       85662   IN      CNAME   mobile-gtalk.l.google.com.
    mobile-gtalk.l.google.com. 66   IN      A       173.194.72.188
    
    Received 79 bytes from 10.10.0.18#53 in 0 ms
    
    ※2012 年 8 月現在、「mtalk.google.com」の分散先として「173.194.72.188」「74.125.31.188」の 2 サーバの存在を確認

    接続維持用の Keep-Alive パケットについて
    tcpdump を走らせ 5228 ポートをめぐる応酬を監視。沈黙状態の 15 分ごとに端末から Keep-Alive パケットが送出される様子が見てとれます。
    # tcpdump  -nl -s 256 port 5228
    tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
    listening on wlan0, link-type EN10MB (Ethernet), capture size 256 bytes
    13:16:23.504179 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 78:103(25) ack 541win 11236 <nop,nop,timestamp 9585688 3638038533>
    13:16:23.560095 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 541:566(25) ack 103 win 274 <nop,nop,timestamp 3638938605 9585688>
    13:16:23.560168 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 566 win 11236 <nop,nop,timestamp 9585694 3638938605>
    13:31:23.568391 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 103:128(25) ack 566 win 11236 <nop,nop,timestamp 9675694 3638938605>
    13:31:23.624242 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 566:591(25) ack 128 win 274 <nop,nop,timestamp 3639838674 9675694>
    13:31:23.624371 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 591 win 11236 <nop,nop,timestamp 9675700 3639838674>
    13:46:23.637744 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 128:153(25) ack 591 win 11236 <nop,nop,timestamp 9765701 3639838674>
    13:46:23.696340 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 591:616(25) ack 153 win 274 <nop,nop,timestamp 3640738749 9765701>
    13:46:23.696469 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 616 win 11236 <nop,nop,timestamp 9765707 3640738749>
    14:01:23.712443 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 153:178(25) ack 616 win 11236 <nop,nop,timestamp 9855709 3640738749>
    14:01:23.770009 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 616:641(25) ack 178 win 274 <nop,nop,timestamp 3641638829 9855709>
    14:01:23.770164 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 641 win 11236 <nop,nop,timestamp 9855715 3641638829>
    14:16:23.783100 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 178:203(25) ack 641 win 11236 <nop,nop,timestamp 9945716 3641638829>
    14:16:23.841258 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 641:666(25) ack 203 win 274 <nop,nop,timestamp 3642538904 9945716>
    14:16:23.841349 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 666 win 11236 <nop,nop,timestamp 9945722 3642538904>
    14:31:23.853398 IP 10.10.0.10.48304 > 173.194.72.188.5228: P 203:228(25) ack 666 win 11236 <nop,nop,timestamp 10035723 3642538904>
    14:31:23.912470 IP 173.194.72.188.5228 > 10.10.0.10.48304: P 666:691(25) ack 228 win 274 <nop,nop,timestamp 3643438980 10035723>
    14:31:23.912555 IP 10.10.0.10.48304 > 173.194.72.188.5228: . ack 691 win 11236 <nop,nop,timestamp 10035729 3643438980>
    

    GCM, C2DM で通知を送ってみる

    以下は自作アプリから C2DM, GCM の順に端末へプッシュ通知を送った時の様子です。 いずれも「173.194.72.188:5228」経由で通知が端末へ配信されていることがわかります。

    11:49:49.268419 IP 173.194.72.188.5228 > 10.10.0.10.44984: P 974497881:974498015(134) ack 436376500 win 272 <nop,nop,timestamp 1641184578 3415344>
    11:49:49.269687 IP 10.10.0.10.44984 > 173.194.72.188.5228: . ack 134 win 11236 <nop,nop,timestamp 3483319 1641184578>
    
    11:49:59.146970 IP 173.194.72.188.5228 > 10.10.0.10.44984: P 134:262(128) ack 1 win 272 <nop,nop,timestamp 1641194459 3483319>
    11:49:59.147103 IP 10.10.0.10.44984 > 173.194.72.188.5228: . ack 262 win 11236 <nop,nop,timestamp 3484307 1641194459>
    



    (tanabe)
    klab_gijutsu2 at 17:20|この記事のURLComments(4)TrackBack(0)
    2012年07月09日

    Android パッケージインストール処理のしくみを追う

    はてなブックマークに登録

    身近な話題でありながら中身のよくわからないことを調べてみるのは興味ぶかいもので、そこから得た知識が意外なところで役に立つことも少なくありません。かねてより Android 環境へアプリケーションをインストールする際に内部でどのような処理が行われるのかに関心を持っていたのですが、知りたい情報がなかなか見当たらないため手元で調査を行いました。その内容を公開します。

    まとめ

    ※クリックすると大きな図が開きます

    ※PackageInstaller を起動した状態での関連プロセスの例

    $ ps
    USER     PID   PPID  VSIZE  RSS     WCHAN    PC         NAME
    root      1     0     268    180   c009b74c 0000875c S /init
    root      36    1     812    244   c02181f4 afd0b45c S /system/bin/installd
    root      33    1     60900  16628 c009b74c afd0b844 S zygote
    system    62    33    126452 28368 ffffffff afd0b6fc S system_server
    app_30    372   33    74876  18852 ffffffff afd0c51c S com.android.packageinstaller
                                  :
    
    ※Package Manager Service と installd プロセスの連携用 UNIX ドメインソケット
    $ ls -l /dev/socket/installd
    srw------- system   system            2012-07-04 11:43 installd
    
    • Android OS 上でパッケージインストールにもっとも深い関わりを持つシステムサービスは、system_server プロセス内で稼動する Package Manager Service と、単独のネイティブプロセスとして動作する installd デーモンである。両者はいずれもシステムブート時に動作を開始する。
    • Package Manager Service は起動時に /data/system/packages.xml ファイルより現システム上のパッケージに関する情報を読み込んで実状と照合し、何らかの不整合があれば再インストール処理を含む自動復旧を試みた上で同ファイルへ最新の状態を反映する。
    • Package Manager Service は起動時に /system/etc/permissions/platform.xml を参照し既定の Android パーミッションに関する情報をロードし、同ディレクトリ下の他の *.xml より当該端末がカバーする機能の情報をロードする。
    • installd デーモンは UNIX ドメインソケット /dev/socket/installd 経由で Package Manager Service から処理要求を受信し、パッケージのインストール・アンインストールまわりの一連の手順のうち root 権限を要する処理を主に担当する。

    • PackageInstaller は通常のパッケージを対話的にインストールするための Android 既定のアプリケーションである。ユーザからインストール指示を受けると PackageInstaller は InstallAppProgress アクティビティを呼び出し、InstallAppProgress は Package Manager Service の installPackage() API 経由で当該パッケージのインストールをシステムへ依頼する。
    • Package Manager Service はパッケージインストール要求を受けると当該パッケージの情報を取得し以下を実施する
      - インストール処理用のキューへパッケージ情報を追加〜順番待ち
      - 当該パッケージの適切なインストールロケーションを判定
      - 新規インストール/更新インストールの判別
      - 所定のディレクトリへの apk ファイルのコピー
      - 当該アプリの UID の決定
      - installd デーモンへ処理を要求
           - アプリケーションディレクトリの作成とパーミッション設定
           - dex コードのキャッシュディレクトリへの切り出し
      - 最新の状態を /data/system/packages.xml および packages.list へ反映
      - インストール完了の旨をパッケージ名を添えてシステムへブロードキャスト
       (新規の場合:Intent.ACTION_PACKAGE_ADDED
         更新の場合:Intent.ACTION_PACKAGE_REPLACED)

    • 上記の PackageInstaller 経由でのインストール経路とは別に、Package Manager Service は内部クラス「AppDirObserver」による非対話式のインストール経路を用意している。 AppDirObserver は android.os.FileObserver の派生クラスであり、所定のディレクトリ直下に .apk の拡張子を持つファイルが配置/削除されると自動的にシステムへのインストール/アンインストールを行う機能を持つ。(※別経路でインストールが行われた場合に重複処理発生を抑制するための機構を備える) Package Manager Service は起動時に /system/app, /data/app, /data/app-private, /system/framwrorks, /vendor/app の各ディレクトリを対象に AppDirObserver クラスのインスタンスを生成する。なお、これらのディレクトリへの書き込みには特権が必要であるため一般のアプリケーションプロセスから直接 AppDirObserver の提供する機構を利用することはできない。

    コード読み

    パッケージインストール処理を構成する一連のソースコードはかなりのボリュームがありますが、コードリーディングの一助として注釈を添えた抜粋を以下に掲載します。コードはいずれも 2012-07-06 現在の内容です。 なお、オンラインソースへのリンクには便宜上 http://android.git.linaro.org/ を使用しています。 続きを読む

    klab_gijutsu2 at 12:45|この記事のURLComments(2)
    2012年05月30日

    inotify で Android 上のファイル I/O を監視する

    はてなブックマークに登録

    先日 Android のソースコードを読んでいた折に、所定の処理においてシステム上で発生するファイル I/O の流れを確認したいと思いました。処理の全体像を把握する上で有用と考えたためです。そこで inotify を利用することにしました。

    Android の Linux カーネルには inotify が含まれており、Android SDK には inotify を利用した FileObserver クラスが用意されています。

    Android では一般のアプリケーションプロセスからアクセスできるファイル・ディレクトリが制限されるため、FileObserver を使い Android アプリの形でツールを用意しても今回の目的にはあまり役に立たないでしょう。こういう時には CUI のコマンドラインツールの方が何かと融通がききます。Android エミュレータ環境への adb shell 接続で得られる root 権限のコンソール上でツールを動かすことにしました。

    Android の notify コマンドを使う

    Android には inotify API を使ってファイル I/O をモニタするコマンドラインツール「notify」が含まれています。 [platform/system/core/toolbox/notify.c]

    # notify
    Usage: notify [-m eventmask] [-c count] [-p] [-v verbosity] path [path ...]
    
    この notify コマンドを使って "/data/local/tmp" ディレクトリを監視しつつ別プロセスから以下のコマンドを実行した時の出力例を示します。
    # pwd
    /data/local/tmp
    # echo aaa > test.txt
    # mv test.txt aaa.txt
    
    # notify -c 10 /data/local/tmp
    /data/local/tmp: 00000100 00000000 "test.txt"  ; 0x00000100:IN_CREATE
    /data/local/tmp: 00000020 00000000 "test.txt"  ; 0x00000020:IN_OPEN
    /data/local/tmp: 00000002 00000000 "test.txt"  ; 0x00000002:IN_MODIFY
    /data/local/tmp: 00000008 00000000 "test.txt"  ; 0x00000008:IN_CLOSE_WRITE
    /data/local/tmp: 00000040 00000033 "test.txt"  ; 0x00000040:IN_MOVED_FROM
    /data/local/tmp: 00000080 00000033 "aaa.txt"   ; 0x00000080:IN_MOVED_TO
    
    ※ I/O イベントの定義は [platform/bionic/libc/kernel/common/linux/inotify.h]

    inotifywait コマンドを使う

    Android の notify コマンドはコンパクトで手軽なツールですが、出力の読みにくさに加え、ディレクトリ配下を再帰的に監視できない点が不便です。改造したりツールを自作するのも面白そうではありますが、Linux 界隈には inotify-tools というパッケージがあることを知り、そこに含まれる inotifywait コマンドを利用することにしました。

    すでに誰かが Android 上で動作する inotifywait をビルドし配布しているのではないかとネット上を探したところ見当たらなかったため手元でビルドを行いました。inotifywait 本体と NDK でのビルド用に手を加えたプロジェクト一式を以下に公開します。

    inotifywait_bin_android.zip
    md5sum [55BF0FD8365A4139D679CE0D6A3A07B3]
    プロジェクト一式(※) - github
    (※) 以下の著作物が含まれます。利用に際しては各ソフトウェアのライセンス規約を遵守して下さい。

    ・ inotify-tools
      著作権者:Rohan McGovern, Radu Voicilas
      ライセンス:GPL version 2
      サイト:https://github.com/rvoicilas/inotify-tools/wiki/
    ・ GNU glibc POSIX 正規表現関数群 (glibc-2.11.3 より)
      著作権者:Free Software Foundation, Inc.
      ライセンス:LGPL version 2.1 or later
      サイト:http://www.gnu.org/software/libc/

    inotifywait に /data /cache /system の各ディレクトリを再帰的に監視させた状態で、自作のアプリ「HelloApp」を起動した際の出力例を以下に示します。

    # ./inotifywait -r -m /data /cache /system
    Setting up watches.  Beware: since -r was given, this may take a while!
    Watches established.
    
    /system/framework/ ACCESS framework-res.apk
    /system/framework/ ACCESS framework-res.apk
    /data/app/ OPEN com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ OPEN com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/dalvik-cache/ OPEN data@app@com.example.helloapp-1.apk@classes.dex
    /data/dalvik-cache/ ACCESS data@app@com.example.helloapp-1.apk@classes.dex
    /data/dalvik-cache/ ACCESS data@app@com.example.helloapp-1.apk@classes.dex
    /data/app/ OPEN com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /system/framework/ ACCESS framework-res.apk
    /data/app/ ACCESS com.example.helloapp-1.apk
    /system/fonts/ OPEN DroidSans-Bold.ttf
    /system/lib/hw/ OPEN gralloc.default.so
    /system/lib/hw/ ACCESS gralloc.default.so
    /system/lib/hw/ ACCESS gralloc.default.so
    /system/framework/ ACCESS framework-res.apk
    /system/framework/ ACCESS framework-res.apk
    /data/app/ CLOSE_NOWRITE,CLOSE com.example.helloapp-1.apk
                  :
    

    (tanabe)
    klab_gijutsu2 at 16:51|この記事のURLComments(0)TrackBack(0)
    2012年05月21日

    Android アプリ「SundayPad」を公開しました

    はてなブックマークに登録

    2012-06-07: バージョン 1.0.1 を公開しました


    予定のない休日の午後に PC の前でごろりと横になって DVD を観たりあちこちのサイトを見て回ったりするのは楽しいのですが、ひとたび「のんびりモード」に浸るとキーボードに触るのが億劫になります。ウェブ検索用にちょっとキーワードを入力したいのだけれど今の体勢を崩すのはなんだか悔しい。こんな時スマホなら音声入力を手軽に使えるので便利です。でも外出先ならともかく休日の自宅でわざわざ端末の小さな画面を追いかけるのはあまり嬉しくない気もする。不精者はあれこれ悩みます。

    ある時ふと思いました。スマホには得意で賢い音声認識だけをやらせて、その結果を簡単に PC 上で利用できるようにすれば何かと便利ではないか?
    さっそく Google 音声認識と連携し認識結果文字列を送信する簡単な Android アプリと、それを受信して PC 上のアプリの所定のフィールドへその文字列を送出する Windows プログラムを作ってみました。期待通りそれはシンプルな割に便利なものになりましたが、しばらく使っている内に「PC のマウスカーソルを操作できればもっと便利になる」ということに気づき、タッチパッド機能と必要最小限のソフトウェアキーボードを加えることにしました。

    同僚たちに試作版を見せるとなるほどと受けもよく、そこで出されたアイディアを含めて機能追加と改良を行いひと通り形になったのが Android アプリ「SundayPad」と、PC 用のエージェントプログラム「SpAgent」です。興味のある方はお試し下さい。

    改訂履歴

    v1.0.1 (2012-06-07)

    • 要求元の Android 端末のアドレスが前回認証ずみのものと同一であれば PC 側での受け入れ確認ダイアログの表示をスキップ
    • Windows 環境でのカーソルの細かい移動をなめらかに

    インストール・ダウンロード

    Android アプリ「SundayPad」 (2012-06-07 更新)


    SundayPad - Google Play のページ

    PC 用エージェントプログラム「SpAgent」

    Windows 版 - SpAgent.zip (2012-06-07 更新)

    Mac 版 - SpAgent.dmg (2012-06-07 更新)

    動作環境

    SundayPad は Android OS 2.1 以上に対応します。
    バージョン 2.2 以上の環境をお勧めします。

    SpAgent の動作は以下のそれぞれ単一のテスト環境においてのみ確認しています

    Windows 版

    • Windows 8 Consumer Preview 32 bit/ 64 bit (デスクトップ環境)
    • Windows 7 Professional SP1 32 bit/ 64 bit
    • Windows Vista Business SP2 32 bit/ 64 bit
    • Windows XP Professional SP3 32 bit
    • Windows XP Home SP3
    • Windows 2000 SP4

    Mac 版

    • Mac OS X バージョン 10.6.8
    続きを読む
    klab_gijutsu2 at 18:23|この記事のURLComments(2)TrackBack(0)
    2011年12月26日

    Android アプリ「AppNetBlocker」を公開しました

    はてなブックマークに登録

    (2015年5月追記)
    この記事に掲載の「AppNetBlocker」は Android 5.0 以降の環境では正しく動作しません。記録としてアプリ本体へのリンクは当面残しますが、コメント欄に何度か記載の通りこのアプリケーションの開発はすでに終了しており、今後改訂を行う予定はありません。ご了承下さい。

    以前から自分自身がほしいと思っていた Android アプリが形になったためマーケットで公開しました。 今回はそのアプリ、「AppNetBlocker」をご紹介します。

    AppNetBlocker は、所定のアプリから「完全なインターネットアクセス」の許可を除去するツールです。実行に root 権限は必要ありません。Android 1.6 以上の環境で動作します。興味のある方はご利用下さい。もちろん無料です。

    (2011/12/26 追記)
    本アプリは、現時点では安全面において不安要素の少なくない Android をめぐる状況において Android 利用者が自分自身を守るためにとり得る対策のひとつを形にしたものであり、他者の権利を脅かすことを目的とするものではありません。
    もし、Android を今よりもさらに安全に利用することが可能となればより多くの利用者・開発者の利益につながることでしょう。本アプリはたとえ僅かでもその一助になればと手がけたものであり、開発の動機もそこにあります。
    しかしながら、一部の方から本アプリと Android マーケット規約とのかねあいを懸念するご指摘がありました。その話題については判断の余地があるものと認識していますが、少なくとも利用者の不安を誘引することはまったく本意ではなく、マーケットでの配布という形態は一時中断することとします。

    実験用のいわゆる「野良アプリ」として apk のダウンロードリンクを当面残しておきます。このリンクから端末へ直接インストールすることはできません。意図を理解される方のみ自己責任でご利用下さい。

      
    [ AppNetBlocker.apk ]
    md5sum [CC8104C9DDE44AD308F09FF22B551575]

    AppNetBlocker とは?

    Android 端末上のデータを狙うマルウェアの問題が取り沙汰されていることもあり、アプリに付与された「許可」の内容は何かと気になります。 特に、それがネットワークアプリやバナー広告を表示するアプリではなく、また、機能面でインターネットへのアクセスが必須とは考えにくい内容のアプリであるにもかかわらず「完全なインターネットアクセス」許可を持っている場合は悩ましいですね。 そんな時には AppNetBlocker が役に立つかもしれません。 続きを読む

    klab_gijutsu2 at 10:00|この記事のURLComments(33)
    2011年11月22日

    AndroidからL2TP/IPsec CRT VPNに接続する

    はてなブックマークに登録

    Android端末には、標準でVPN接続機能が搭載されています。

    その中でもL2TP/IPsec方式では、x509証明書による認証がサポートされていて、端末ごとに個別の証明書を発行するなどの方法でセキュリティの高いVPN接続を行うことができます。

    KLabでは、社内用の認証局を運用していて、社員が自分のユーザ名の入ったクライアント証明書を持てるようになっています。
    管理用サイトへのブラウザでのアクセスのほか、社内の無線アクセスポイントもWPA2 Enterprise EAP-TLS認証に対応させて、クライアント証明書による強力な認証を活用しています。
    もちろん、万が一の秘密鍵流出の際には、認証局側でCRLにより証明書を失効させることができるよう整備しています。

    本記事では、クライアント証明書を使ったAndroid端末からのL2TP/IPsec VPN接続を試してみたいと思います。

    Android 2.2系までの端末では・・・

    残念ながら、Android 2.2系までの端末では、組み込まれている racoon(ipsec-tools)に問題があり、x509証明書による認証が実質的に使えない状態です。理由は以下のとおりです。

    IPsecでは、IKEの認証フェーズ1にて、お互いのIDを交換します。
    このIDには、「ID_IPV4_ADDR」・「ID_FQDN」などいくつかの種類があり、x509証明書を認証に使う場合には、証明書の識別名を使う「ID_DER_ASN1_DN」というIDタイプを使うことができます。

    本記事のように、接続元IPアドレスを固定せずx509証明書を使う環境では、ID_DER_ASN1_DNタイプのIDを使うのがベストですが、Android 2.2系のracoonでは、「L2TP/IPsec CRT VPN」を選んでいても常にID_IPV4_ADDRタイプのIDがサーバに通知されてしまいます。

    x509証明書による認証で、ID_IPV4_ADDR等をIDに使うことは可能ですが、この場合は使用する証明書のsubjectAltName拡張等のフィールドに「IP:192.168.0.1」等の値を含めておく必要があります。
    そしてモバイル端末では、キャリアから動的に割り当てられるIPアドレスを使いますから、事前にIPアドレス等を記載した証明書を用意することも難しいです。
    このため、Android 2.2までのOSを搭載した端末では、証明書によるIPsecは実質使えないのです。

    この問題は、バグレポートされていて、Android 2.3では改善されています。 そのため、以降はAndroid 2.3系のOSを搭載した端末を前提とします。

    認証に使う証明書とCA

    認証に使うクライアント証明書・サーバ証明書、発行するCAには、Android端末の実装上、いくつかの制約があるため注意が必要です。

    • サーバ証明書のコモンネームには、サーバのFQDNを記載する

    一般のWebサーバ用の証明書と同様に、コモンネームに記載されたFQDNと、端末の設定画面に入力した接続先サーバ名は一致する必要があります。

    • サーバ証明書は、シングルルートCAから発行する

    サーバ証明書やクライアント証明書の発行元は、いわゆるオレオレ認証局を使うことができますが、自己署名しているルートCAから直接発行する必要があります。

    Android端末のIPsecの処理では、端末ビルトインの証明書ストアは使われず、設定画面で指定したクライアント証明書とCA証明書だけが認証に使われます。ですから、サーバ証明書の検証に必要なルート証明書は、CA証明書としてインポートしてあげれば良いことになります。
    しかし、Android端末のCertInstallerでは、複数の証明書をまとめてインポートできない仕様らしく、PEMフォーマットの証明書を複数連結したファイルをインポートしようとしても、「CA証明書1件」と表示されて、中間証明書を含めてインポートすることができません。
    IPsecの認証プロトコル中でやり取りできる証明書も各方向1枚だけのため、インポートしたルート証明書から直接サーバ証明書を発行するしか手がありません。
    (IKEのプロコトル仕様では、複数の証明書ペイロードを送ることができます。 実際にstrongSwanに改造を加えて実験してみましたが、なんとracoon側が1つ目の証明書ペイロード以外を無視する実装になっていたため失敗しました・・・)

    • クライアント証明書については、自由度は高い

    クライアント証明書の検証は、サーバ側の仕事ですので、サーバに必要な中間証明書をインストールすればどのような証明書でも使用することができます。

    openswanやstrongswanでは、クライアントのID_DER_ASN1_DNタイプのIDにワイルドカードを指定できるため、証明書の識別名についても自由度があります。
    「rightid = "C=JP, ST=Tokyo, O=KLab Inc., CN=*」と設定すれば、各ユーザに個別に発行している「C=JP, ST=Tokyo, O=KLab Inc., CN=USERNAME」のような識別名の証明書をすべて使えるようになります。

    端末への証明書・秘密鍵のインポート方法

    インポートするクライアント証明書と秘密鍵は 「.p12」拡張子のPKCS#12形式、CA証明書は「.crt」拡張子のPEM形式もしくはDER形式にして、SDカードのルートかdownloadディレクトリに置きます。
    Android端末のCertInstallerは「.p12」または「.crt」の拡張子の付いたファイルのみインポートしようとしますので、必ず所定の拡張子にする必要があります。

    インポートは、設定の「現在地情報とセキュリティ」メニューの「SDカードからインストール」から行えます。

    証明書のインポート画面

    それぞれ読み込みできると証明書名を聞かれますが、これは後ほどVPN設定を行う際、証明書を選ぶメニューでの名前になりますので、わかりやすい名前を設定しておきます。

    Android端末では、インポート済みの証明書の一覧表示や削除のインタフェースが充実していないので、証明書名には「シリアル番号+コモンネーム」等、証明書の個体を識別しやすい名前にするとよいでしょう。

    サーバサイドの準備

    Android端末からのVPN接続を受け入れるサーバ側の構築を行います。

    使用するソフトウェアは、以下のとおりです。

    • openswan-2.6.36
    • xl2tpd-1.3.0 (後述のチューニングのためにパッチ適用あり)
    • ppp-2.4.5

    それぞれ、ビルドオプションに特別なものを指定する必要はありません。

    カーネルのESPサポートは、linux-3.0.4の標準のものを使用しています。
    また、LinuxでのIPsec実装は、openswanのほかにstrongswanもありますが、strongswanではVPN切断後の再接続が上手くいかないという問題が発生したため、openswanを使用します。

    Android端末からのL2TP/IPsec接続では、VPN切断時にサーバ側のSAをうまく削除してくれないという問題があるようで、サーバ側にlifetime時間までSAが残ってしまいます。strongswanを使用するとSAが残っている間は再接続(2本目のSA)が出来ず通信不能になってしまいました。

    openswanの設定ファイル(ipsec.conf)は以下のとおりです。

    config setup
        interfaces = %defaultroute
        syslog = auth.error
        plutodebug = "control"
        uniqueids = no
        nat_traversal = yes
    
    conn Android-L2TP-IPsec
        auto = add
        type = transport
        keyexchange = ike
        auth = esp
        authby = rsasig
        pfs = no
        keyingtries = 1
        ikelifetime = 8h
        keylife = 8h
        rekeymargin = 10m
        left = %defaultroute
        leftid = "C=JP, ST=Tokyo, O=KLab inc., (略)"
        leftrsasigkey = %cert
        leftcert = Android_IPsec_Server.pem
        leftupdown = "/bin/true"
        leftprotoport = 17/1701
        right = %any
        rightca = "C=JP, ST=Tokyo, O=KLab Inc., (略)"
        rightid = "C=JP, ST=Tokyo, O=KLab Inc., (略)"
        rightrsasigkey = %cert
        rightprotoport = 17/%any
    

    読み込む証明書の指定(leftcert)、各ID等は環境に合わせて調整する必要があります。

    サーバ証明書を/etc/ipsec.d/certsに、秘密鍵を/etc/ipsec.d/private、クライアント証明書の検証に必要な中間CA証明書を/etc/ipsec.d/cacertsにそれぞれ設置します。
    ファイル名は、OpenSSL流のハッシュ形式にしなくても自動的に読み込んでくれます。
    また、秘密鍵の復号化に必要なパスワードは/etc/ipsec.secretsに以下のように記述します。
    (leftcertに指定しているAndroid_IPsec_Server.pemに対応する秘密鍵が、/etc/ipsec.d/private以下にAndroid_IPsec_Server.keyの名前で保存されている前提です。)

    : RSA Android_IPsec_Server.key "hogefuga"
    

    xl2tpd側の設定ファイル(xl2tpd.conf)とpppオプション(ppp-options)は以下のとおりです。
    トンネルに割り当てるIPアドレス(local ipやip range)もお好みで選んでください。

    [global]
    listen-addr                 = 0.0.0.0
    port                        = 1701
    debug network               = no
    debug state                 = no
    debug tunnel                = no
    
    [lns default]
    pppoptfile                  = /etc/xl2tpd/ppp-options
    hostname                    = gw1
    exclusive                   = No
    local ip                    = 10.100.254.1
    ip range                    = 10.100.254.128 - 10.100.254.254
    length bit                  = yes
    require chap                = yes
    refuse pap                  = yes
    require authentication      = yes
    

    pppoptfileでは、認証可否の設定の他、Android端末に通知するDNSサーバを設定します。

    auth
    refuse-pap
    require-chap
    ms-dns 10.100.254.1
    

    上記設定では、CHAP認証を行う設定になっています。許可するユーザ名・パスワードは/etc/ppp/chap-secretsに記述します。

    USERNAME * "PASSWORD" *
    

    PAP/CHAPともにrefuseとすると、L2TP上では認証しない設定にすることができますが、Android端末ではユーザ名・パスワードの入力が必須になっているため、ダミーのパスワードを入力する必要があります。

    Android側からのVPN接続を行うと、端末からのすべての通信がトンネルを経由してサーバへルーティングされます。
    サーバ側では、トンネルごとにPPPインタフェースが作られますので、適宜インターネットや社内のシステムに対してルーティング・アクセス許可してあげれば通信可能になります。
    名前解決に使われるDNSサーバはpppoptfileのms-dnsで指定したサーバが使用されます。

    Android端末サイドのVPN設定

    Android端末に、VPN接続のプロファイルを作ります。

    設定の「無線とネットワーク」から「VPN設定」とたどります。
    「VPNの追加」をタップして「L2TP/IPsec CRP VPNを追加」を選びます。

    各項目を以下のように設定して保存すると、プロファイルが作られます。

    • VPN名: 任意の名前を設定します
    • VPNサーバの設定: 接続するサーバのFQDNを指定します
    • L2TPセキュリティ保護を設定: チェックを外したままにします
    • 証明書を設定する:インポートしたクライアント証明書を選びます
    • CA証明書を設定する:インポートしたCA証明書を選びます
    • DNS検索ドメイン: 社内のドメイン名等を任意で設定します
    VPNプロファイル画面

    保存後、VPN欄に現れたプロファイルをタップして、サーバのchap-secretsに記述したユーザ名・パスワードを入力し接続します。

    画面上部に「VPN [プロファイル名] が接続されました」と出ればVPN接続は完了です。
    切断する場合には、プロファイルを再度タップすれば切断されます。

    VPN接続中に通信できなくなる問題

    筆者の環境では、長時間接続していると、突然通信不能になる症状が発生しました。

    調査してみたところ、キャリアあるいは電波状況によっては、不定期的にパケロスする状況が発生するようで、L2TPサーバ側で定期的に送っているHelloメッセージがロスし最大再送回数に到達してしまったためにトンネルが閉じられているという状態でした。

    Android端末側から、L2TPトンネルの切断を検知できるようなメッセージを送出できると良いのですが、端末標準の設定インタフェースでは実現できないため、サーバサイドで出来る限りの調整を行なってみます。

    今回使用したL2TPサーバ(xl2tpd)では、HelloメッセージによるKeepaliveの間隔(60秒)、コントロールメッセージの再送間隔(1秒)・最大再送回数(5回)がソースコード中にハードコードされています。

    この再送条件では、電波状況等により通信品質が変化するモバイル環境には厳しすぎますので、設定ファイルからパラメータを変更できるようにするパッチを書いてみました。

    xl2tpd-1.3.0-add-ctrl-retrans-opt.patch
    (2012/02/17:コメントにて、1.3.1では上記パッチが当たらないという連絡をいただきましたのでdiffを取り直しました: xl2tpd-1.3.1-add-ctrl-retrans-opt.patch )

    このパッチを適用すると、設定ファイルのglobalセクションに以下の3つのパラメータを設定できるようになります。

    • hello delay (Helloメッセージの送信間隔)
    • ctrl retrans max (最大再送回数)
    • ctrl retrans delay (再送回数)

    各パラメータをそれぞれ、Hello送信間隔を5分(hello delay = 300)、コントロールパケットの再送間隔を10秒(ctrl retrans delay = 10)で最大再送回数を18回(ctrl retrans max = 18)とすれば、最大3分程度通信不能な時間が発生してもトンネルを閉じられずにすみます。
    長すぎる値を設定すると、本当に端末がオフラインになってしまっていても、長時間サーバ側にトンネルが残り続けてしまうため注意が必要です。

    このパッチによるパラメータの変更は、あくまで切断される条件の緩和ですので、確実にVPNが切れなくなるわけではありませんが、多少の効果はあると思います。


    #dSn
    klab_gijutsu2 at 17:49|この記事のURLComments(9)
    2011年10月25日

    エンコードされた AndroidManifest.xml を読む

    はてなブックマークに登録

    アプリケーションマニフェストである AndroidManifest.xml をはじめ、Android アプリを構成する各種 XML ファイルは apk へのパッケージングの段階でパースされ独自のバイナリ形式にエンコードされます。 このファイルを扱う処理をコンパクトに実装したいと思ったのですが、現時点では形式に関する公式の資料が存在しないことがわかり aapt を参考に手元でフォーマットの分析を行いました。パーサ試作例とともにその内容を公開します。

    1. データ例

    (A) テストアプリ「MyApp」用に記述した生の AndroidManifest.xml

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        package="jp.klab.sample.myapp"
        android:versionCode="1"
        android:versionName="1.0">
        <uses-sdk android:minSdkVersion="4" />
        <application android:icon="@drawable/icon" android:label="@string/app_name">
            <activity android:name=".MyApp"
                android:label="@string/app_name"
                android:launchMode="singleTask"
                android:excludeFromRecents="false"
                android:configChanges="orientation|keyboardHidden">
                <intent-filter>
                    <action android:name="android.intent.action.MAIN" />
                    <category android:name="android.intent.category.LAUNCHER" />
                </intent-filter>
            </activity>
        </application>
        <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    </manifest>
    

    (B) パッケージ後の MyApp.apk に含まれる エンコードずみ AndroidManifest.xml のダンプ

    (C) 試作コードにより上記 (B) に含まれる情報を XML ツリー形式に再構成したもの

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        android:versionCode="1"
        android:versionName="1.0"
        package="jp.klab.sample.myapp">
        <uses-sdk android:minSdkVersion="4" />
        <application android:label="@0x7F050001" android:icon="@0x7F020000">
            <activity
                android:label="@0x7F050001"
                android:name=".MyApp"
                android:excludeFromRecents="false"
                android:launchMode="2"
                android:configChanges="0x000000A0">
                <intent-filter>
                    <action android:name="android.intent.action.MAIN" />
                    <category android:name="android.intent.category.LAUNCHER" />
                </intent-filter>
            </activity>
        </application>
        <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    </manifest>
    
    続きを読む
    klab_gijutsu2 at 08:47|この記事のURLComments(0)TrackBack(0)
    2011年09月29日

    Android アプリケーションが起動するまでの流れ

    はてなブックマークに登録

    プログラム開発のために Android 上でアプリが起動するまでの過程を調べてみました。備忘をかねて、ソースコードをひと通り追跡した記録をここに控えます。

    まとめ

    ※クリックすると大きな図が開きます

    • Zygote(ザイゴート)プロセスは、Android システムブート時に起動し DalvikVM 本体と Android プログラムの実行に必要なダイナミックリンクライブラリと Java のクラスライブラリをロードした状態で待機する常駐プロセスである
    • Zygote プロセスの目的は、同プロセスを fork することによりプログラム実行用のプロセス環境を素早く効率的にシステムへ提供することにある
    • UNIX ドメインソケット /dev/socket/zygote が Zygote プロセスへのインターフェイスであり、同ソケットにプロセス生成要求を送出すると Zygote はプロセス fork を実行する
    • system_server プロセスは同じくシステムブート時に起動するシステムプロセスである。Activity Manager をはじめ数多くの Android サービスはこの単一プロセス内で稼動する
    • ユーザが Android アプリ起動の操作を行うと Activity Manager 経由で Zygote にプロセス生成要求が送出される。ここで fork されるプロセスがアプリ用のプロセスとなる。つまり、すべての Android アプリケーションプロセスは Zygote の子プロセスである
    • アプリケーションプロセスの uid, gid は、当該アプリのインストール時にシステムが割り当てたものを Activity Manager が Zygote へのプロセス生成要求メッセージ内で指定し、Zygote がそれを設定するしくみ
    • Activity Manager 経由で Zygote から fork された アプリ用新規プロセスは、初期処理の段階で Activity Manager とのプロセス間通信を通じて起動対象アプリの情報を取得し、それをもとに所定のアプリをロード〜実行する。また、起動後のアプリケーションプロセスはライフサイクル管理を含め Activity Manager を中心とする文脈の中で一元管理される
    ※プロセスの親子関係に注意
    $ ps
    USER     PID   PPID  VSIZE  RSS     WCHAN    PC         NAME
    root      1     0     268    180   c009b74c 0000875c S /init
    root      33    1     60924  16448 c009b74c afd0b844 S zygote
    system    62    33    130944 27412 ffffffff afd0b6fc S system_server
    app_29    122   33    82996  21988 ffffffff afd0c51c S com.android.launcher
    app_9     476   33    78500  20568 ffffffff afd0c51c S jp.klab.stone
                                  :
    

    コード読み

    以下はソースコードを追った記録です。
    なお、例の一件以来 android.git.kernel.org のダウン状態が続いているため、記事中のコードは android.git.linaro.org 上の最新版(2011年 9月末現在)からの引用です。
    続きを読む

    klab_gijutsu2 at 14:09|この記事のURLComments(0)TrackBack(1)
    2011年08月31日

    Android 上のアプリから SSL クライアント認証の必要なサーバへアクセスする方法

    はてなブックマークに登録

    Android の Web ブラウザが SSL クライアント認証に対応していないのは不便だという話を以前このブログに書きました。記事では、Android 上の stone に SSL まわりの処理を代行させる方法について触れました。
    Android 上のブラウザから SSL クライアント認証の必要なサーバへアクセスする方法


    それから半年、本家の Android 標準ブラウザは現在も未対応のままですが、「SandroB」のようにクライアント認証に独自対応したブラウザも登場し始めています。こうした動きは Android ユーザとしてとても喜ばしいことで、対応環境の拡大や機能面の向上など今後の進化が大いに期待されます。

    一方で、たとえば KLab の「VPN-Warp」のような SSL-VPN システム経由での利用が想定されるアプリケーションは Web ブラウザばかりではありません。私の場合、出先から自宅 PC へのアクセスには VNC を VPN-Warp+stone 経由で使っています。VNC ビューワそのものは SSL に対応していませんが、通信に stone を介在させることで所定のアプリ本体が SSL まわりの実装を持っていなくてもそういう使い方が可能となるわけですね。もちろん、人によってはここで言う「所定のアプリ」に、「普段使い慣れているブラウザ」が該当する場合もあるでしょう。

    冒頭の記事を書いた時点では Android 用 stone は何かと面倒な CUI ベースでしたが、現在は通常の Android アプリとしてマーケットで公開しています。社内からの声もあり、今回はこのアプリ版に固有の Android キーストアへ証明書をインストールする機能に関する話題に触れながら、stone for Android でクライアント証明書を利用する手順等を紹介します。 続きを読む
    klab_gijutsu2 at 10:06|この記事のURLComments(0)TrackBack(0)
    2011年08月09日

    root 化ずみ端末に対応した Android アプリを書く方法

    はてなブックマークに登録

    Android 端末を root 化するユーザが増えています。
    一部の開発者向け製品以外では root 権限を取得するための公式の手段は提供されていないので、その方法を自分で探索するにせよ誰かが開拓した手順をトレースするにせよすべては自己責任であるわけですが、一方で root 権限を必要とするアプリはマーケットにもあれこれ出回っており、つまりはこの件に関する安全性と利便性のトレードオフに満足できない向きが世界中に大勢いるということでしょう。

    その状況自体もいろいろ興味ぶかいのですが、ところで、その「root 権限を利用するアプリ」というものはどうやって書けばいいのでしょう?Google 公式の開発者向け資料は言うに及ばず、その他のリソースにも今のところほとんどこの話題に関する情報は見当たりません。そこで今回は、実際に手元のアプリを root 権限での実行に対応させる試みを通じて得た情報やノウハウを紹介したいと思います。 続きを読む

    klab_gijutsu2 at 21:00|この記事のURLComments(4)TrackBack(0)
    2011年06月24日

    GUI 版「stone for Android」公開のお知らせ

    はてなブックマークに登録

    重要なお知らせ (2018年8月2日)

    本アプリは Android NDK を使用してビルドした 32ビットのネイティブバイナリを含んでいます。 先般 Google はこのようなアプリの Google Play での扱いについて以下の発表を行いました。

    この話題を受け今後の対応を検討してきましたが、現時点での結論として、本アプリの 64ビット対応を行う予定はありません。そのため、現行のバージョン 1.0.3 が最終版となる見通しです。 そういった事情により、念のため現行版の apk のコピーを以下へ配置しています。

    Android OS での 32ビットバイナリサポートがいつまで維持されるか現時点では不明ですが、必要な場合にはこれをご利用下さい。本アプリをご利用の皆様へ謹んで御礼申し上げます。


    SSL 対応のパケットリピータ「stone」を Android 上で動かすことにはさまざまなメリットがあります。 以前このブログに次の記事を書きました。

      ・「パケットリピータ stone を Android へポーティング
      ・「Android 上のブラウザから SSL クライアント認証の必要なサーバへアクセスする方法


    当時は Android NDK でビルドしたネイティブの stone バイナリとビルド用リソースの一式を公開しましたが、その後 GUI 版の開発を手がけ、「stone for Android」として Android マーケットへの登録を行いました。

    これはいわゆる「普通の Android アプリ」ですから端末へのインストールも取りまわしもとても簡単です。 興味のある方はぜひご利用下さい。ソースコードも公開しています。


    ブラウザから端末へ PUSH インストールの可能なページ
    続きを読む
    klab_gijutsu2 at 17:55|この記事のURLComments(13)
    2011年02月15日

    Android上のブラウザからSSLクライアント認証の必要なサーバへアクセスする方法

    はてなブックマークに登録

    ■ 初めての Android 端末(IS01)のブラウザで困ったこと

    KLab では、社員が社外から社内 LAN 上のサーバへ安全にアクセスするために、自社製の SSL-VPN システムである「VPN-Warp」を使っています。これにより、社員はインターネット上の専用の中継サーバへアクセスすることで所定の社内サーバと通信することが可能です。この中継サーバは不正利用を防ぐためにクライアントからの接続要求時に所定の電子証明書の提示を求める「SSL クライアント認証」を行います。KLab の発行した有効な証明書を提示するクライアントからの要求のみがこれを通過できるというわけですね。

    さて、筆者は 2010 年末に IS01 を入手し、あれこれ試していたところひとつ困ったことがありました。Web ブラウザが SSL クライアント認証に対応していないため上記の VPN-Warp 中継サーバ経由で社内のサーバへアクセスすることができないのです。スマートフォンに馴染みが薄いためとりあえずの実験用と割りきって確保した端末ではありますが、その点以外は結構気に入っていたため残念に思いました。

    ■ 「stone」を併用することで解決

    先日このブログで Android 用の「stone」一式を公開しましたが、実のところ stone を Android 上で動かしたいと考えたのは、手元の IS01 のブラウザから VPN-Warp 経由で社内サーバへアクセスすることが目的でした。パケットリピータ stone の SSL 対応機能はクライアント認証をサポートしているため、ブラウザと VPN-Warp 中継サーバの間に stone のプロセスを介在させてやれば SSL まわりの処理一式を stone にまかせることができるのです。


    stone に クライアント認証対応を含めて SSL 処理を代行させる

    実機でこの操作を行った様子を以下に示します。
    # 図中の stone コマンドラインは次の内容です
    
    stone -d -q pfx="cert.pfx" -q passfile="pass.txt" \
          relay.klab.org:443/ssl localhost:8888

    ■ 実は最近の端末のブラウザもクライアント認証未対応?

    さて、ブラウザがクライアント認証に対応していないという制約は筆者の持つ IS01 が旧い Android 1.6 ベースであることに起因するものであって Android 2.x 端末ではきっと解消されているのだろうと漠然と想像していたのですが、実際にはそうではないことを最近になって知りました。
    この冬の新機種ブームも相まって社内の Android ユーザが増加する中、「外からイントラにアクセスできたよ!」という声が聞こえてこないのです。利用者に事情を尋ねるとやはりブラウザがクライアント認証をハンドルしない様子とのことでした。しばらく Android から離れていたため事情がわからず情報を探してみると、どうやら本件への対応は今後の課題という扱いのようです。

       Android プロジェクト公式フォーラムより
      ・Issue 11231: Provide support for managing CA and client certificates
      ・Issue 8196: Enhancement: Client Certificate Authentication in Browser

    ご存知の方はとっくにご存知の話だと思いますがちょっと意外に感じました。世間一般に SSL クライアント認証を活用したサービスがあまり多くないことの影響なのかもしれません。

    ■ 同じ問題で困っている方へ

    本件は Android の近い将来のバージョンでの解決が期待されますが、自分と同じく今現在の制約に困っている方の参考になればとこの記事を書くことにしました。Android 用 stone のダウンロードリンクと導入・実行方法を次の記事に掲載しています。

      ・DSAS開発者の部屋:パケットリピータ「stone」を Android へポーティング

    今のところ単体の ELF 実行形式であるため CUI に慣れていないと多少扱いにくいという点と、Android では hosts ファイルの書き換えが一筋縄ではいかない事情もあり接続先によってはブラウザでの localhost 指定が通らない可能性がありますが、本記事を最後まで読み進められた知見と技術力で上手くご利用下さい。なお、stone の詳細については下記サイトの解説記事が参考になるでしょう。

    stone 公式サイト:「Simple Repeater 'stone'」


    (tanabe)
    klab_gijutsu2 at 13:24|この記事のURLComments(8)TrackBack(0)
    2010年12月13日

    パケットリピータ「stone」を Android へポーティング

    はてなブックマークに登録

    オープンソースの多機能パケットリピータソフトウェア「stone」を Android 用にビルドしてみました。
    当初は個人的に使用することが目的でしたが、原作者の仙石 浩明 (Hiroaki Sengoku) さんの了承のもと、ビルドずみバイナリを含むリソース一式を非正式版として公開します。他のプラットフォームで stone の便宜に馴染んでいる方はお試し下さい。なお、stone 本体の使用方法等についてはこの記事では触れません。公式サイトの解説記事を参照して下さい。 続きを読む
    klab_gijutsu2 at 17:34|この記事のURLComments(4)TrackBack(0)
    2010年12月02日

    Android NDK でネイティブ CUI プログラムを書く!

    はてなブックマークに登録

    (2015年6月追記)
    2014年10月に公開された Android 5.0 (Lollipop) 以降では PIE (Position Independent Executable) 以外のネイティブ実行形式がサポート外となったため注意が必要です。詳細は本ブログの次の記事を参照して下さい。

    「Android で今後ネイティブ実行形式を扱う際に注意すべきこと」(2015年6月17日掲載)


    今ならとても有利な条件で au の Android 端末を入手できることをネットで知り、先週の休みに地元の家電量販店へ足を運んでみました。 Android 1.6 搭載のこの「IS01」に今後 OS のバージョンアップサポートが適用されない旨の発表を聞いた時はお気の毒に・・と思っていましたが、極端に低いコストで実機を持てるのなら話は別です。とりあえずの実験用としていろいろ使えることでしょう。

    そんなわけでこの何日間か Android SDK を勉強しながら Java のプログラムを書いたりしていたのですが、Android NDK を使えば ネイティブコードの JNI 用ライブラリだけではなく スタンドアロンの CUI プログラムも作成できることを知りました。

    それはそれで面白いので Hello world! ではなくさっそく変なプログラムを C 言語で書いてみました。Android 端末上で次のように動作します。
    ・ 簡易 HTTP サーバとして振る舞い PC 上の web ブラウザと対話できる
    ・ フォームから指定された場所の地図を Android 端末上に表示する

    # プログラム作成中の自問自答
    Q:「もしかすると普通に PC で Google Maps を使う方が便利ではないか?」
    A:「GPS の恩恵あり!入力の楽な PC キーボードを使えるのも良し!」
    Q:「なら普通に SDK でそういうアプリを作ればよいのではないか?」
    A:「Android の根っこは Linux!C でさくさく書くのが粋というものだ!」

    粋かどうかはさておき興味のある方はご覧下さい。ソースコードも掲載します。 続きを読む

    klab_gijutsu2 at 21:22|この記事のURLComments(6)TrackBack(0)
    2008年01月07日

    Ant とテキストエディタではじめる Android

    はてなブックマークに登録

    皆様、新年あけましておめでとうございます。今年もよろしくお願いいたします。

    さて、The Open Handset Alliance が発表した Android SDK ですが、既に Eclipse + Eclipse Plugin を使用してお試しになられた方もいらっしゃるかと思 います。

    私は通常の開発業務で Eclipse を使っているのですが、plugin を入れすぎたせ いか動作が重いので、もっと軽い環境で開発できたらと思ってました。という 訳で Emacsと か vi などのテキストエディタを使って Android のアプリケーション を開発できる方法をご紹介します。

    続きを読む
    klab_gijutsu2 at 11:38|この記事のURLComments(0)TrackBack(1)
    Blog内検索
    Archives
    このブログについて
    DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
    このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
    最新コメント