Rubotoを使い、Android + nfc-felica ライブラリで、SuicaとEdyの履歴を読んでみた

以前、RubotoでNFCIDmなどを読んでみたため、今度はSuicaEdyの履歴などを読もうかと考えましたが、自分で実装するには時間がかかりそうでした。

そのため、良いものがないかを探したところ、Kazzzさんのライブラリ「nfc-felica」がありました。ありがたい限りです。
nfc-felica - android nfc access felica, ISO15693 raw command - Google Project Hosting


ライブラリに付属しているアプリを見てみると、Suicaの読み取りについては実装されていたものの、Edyなど他のFeliCaの読み取りについては実装されていませんでした。

そこで、ライブラリの使い方を学びがてら、SuicaEdyの履歴読み取りをするアプリをRubotoで作ってみることにしました。

 

環境

Platform JDK ant Ruby ruboto jruby-jars Device API level
Windows7 x64 1.7.0_25 1.9.1 RubyInstaller 1.9.3-p448 0.14.0 1.7.4 Nexus7 2012 android-17


2013.09.02追記
当初は、Rubotoのバージョン0.13.0で作成しましたが、0.14.0リリース後に動作の確認ができたため、rubotoの表記を0.14.0に修正しました。
2013.09.02追記 ここまで

 

生成

今回はライブラリを変更・使用しているため、パッケージ名を取得してみました。
あと、いつも通り、Windowsでは生成後にメモリを修正しておきます。

ruboto gen app --package jp.gr.java_conf.thinkami.felica_read --target android-17 --with-jruby


パッケージ名は以下の場所にて取得しました。
Package BOF Home Page



 

AndroidManifestの追記

以前NFCを読んだ時と同じく、

  • permission
  • GooglePlayでの表示

に関して追記しました。


なお、intent-filterについては、後述のForeground Dispatch Systemを使うことにしたため、記載していません。

Rubuto0.13.0では起動に時間がかかるために、起動中にタグをロストすることもあったことから、Foreground Dispatch Systemを使った方がいいのかなという印象です。

 

ライブラリ「nfc-felica」の準備

SubversionによるCheckout

ライブラリのプロジェクトページよりCheckoutします。
TortoiseSVNであれば、SVN Checkoutで「URL or repository」に以下を指定して実行します。

http://nfc-felica.googlecode.com/svn/

 

ライブラリのコピー

今回、Suicaの読み取りはプロジェクトに同梱されているJavaソースコードを利用するため、以下の2つのフォルダを丸ごとコピーします。

本来なら、必要なものだけが良いはずですが、今後色々とやるかもしれないと考え、全部コピーしてから不要なものを削除することにしました。
 

コピー元

コピー先


なお、Javaのパッケージに合わせて両方とも同じフォルダへと統合しました。

 

Suica用DBのコピー

Suica用のdbもコピーしておきます。
最新版ではないかもしれませんが、今回はこのまま進めます。


コピー元

コピー先

  • felica_read\assets\StationCode.db

 

不要なjavaファイルの削除

ここまでの状態でビルドをしようとしても、いくつかエラーが出てビルドができません。
そのため、不要なものを削除し、ビルドできるようにします。


1. アプリやFragmentの削除
Support LibraryのFragmentを使っているため、「エラー:パッケージandroid.support.v4.appは存在しません」と表示されます。
今回は、アプリやFragmentがなくても動作することから、以下を削除します。

 

2. taskフォルダ以下
今回はFeliCaへの書き込みを行わないため、taskフォルダ自体を削除します。

  • felica_read\src\net\kazzz\task


ちなみに、今回使用しなかったものの、Support Libraryのインストール方法は、以下の通りです。

以前は「Android Compatibility Package」という名前でしたが、途中で「Support Package」に代わり、現在はAndroid SDK Managerに表示される「Support Library」になったようです。


また、ライブラリのFragmentを継承してActivityを作ろうかとも思いましたが、

class HogeActivity < Fragment
end

としたところ、以下のエラーが出たため、今回はFragment継承の方向を諦めました。

(TypeError) superclass mismatch for class HogeActivity

 

ライブラリのパスの修正

Suicaのdbを使うため、felica_read\src\net\kazzz\felica\suica\DBUtil.javaのパスを変更します。
(変更しない場合、以下のエラーが発生します)

Failed to open database '/data/data/net.kazzz/databases/StationCode.db'


変更箇所は 45行目。

// private static final String DB_PATH = "/data/data/net.kazzz/databases/";
private static final String DB_PATH = "/data/data/jp.gr.java_conf.thinkami/databases/";

 

ライブラリ「nfc-felica」を使う上で参考にしたところ

実際にライブラリを使用していたり、使った時のソースコードが公開されていたので、そちらが参考になりました。


なお、ライブラリのアプリではFragmentを使っていますが、Suica側の書き直しなどを考えて、Fragmentは使わないようにしました。
NfcFeliCaTagFragmentを作る - Kazzzの日記



 

FeliCaの仕様など

フォーマットについて

FeliCa LibraryのWikiに詳しくまとめられています。今回使用するSuicaEdyの場合は、以下のページが該当します。


ただし、今回のEdyについては残額情報ではなく利用履歴の取得なので、サービスコードは以下にある通り「170F」を使います*1


なお、Edyから履歴を読むコード例が以下にありました。注意点などもあり、詳しくためになりました。
Android NFCでFelicaとたわむれてみる


FeliCaの技術情報やAndroidから扱う際の参考資料です。

 

Android関連で調べたこと

ScrollViewについて

今回、利用履歴を一つのTextViewへ入れるようにしました。
利用履歴が多い場合、TextViewの改行量も増え、その結果、画面内には収まらなくなりました。
そこで画面をスクロールする方法がないかを調べたところ、ScrollViewがあることが分かりました。
Rubotoではどこまで実装されているのか分かりませんでしたが、Googleグループに動作した情報がありました。
Google グループ


そこで、上記を参考に、以下のようなソースを書きました。
@balanceのTextViewはスクロールなしの固定、@historyのTextViewはスクロールありとなります。

@main = linear_layout orientation: :vertical do
        @balance = text_view text: 'Felicaを読んで下さい', text_size: 36
        text_view text: '-' * 60
        scroll_view do
          linear_layout orientation: :vertical do
            @history = text_view text: ''
          end
        end
      end


ちなみに、XMLでの記述方法は、以下が参考になりました。
画面全体を縦方向にスクロールできるようにする - Droidくん「JavaとXMLの魔境、Androidフレームワークの世界へようこそ!」 - Android 開発コミュニティ

 

フォアグラウンドディスパッチを使い、アプリが起動している時のみFeliCaに反応させる

intent-filterでFeliCaのみに反応させることも考えましたが、FeliCaを発見するたびにアプリの起動を迫られるのが面倒でした。
そこで、アプリが起動している時のみFeliCaに反応させる方法がないかを調べたところ、 Foreground Dispatch Systemという仕組みを使えば良さそうなことが分かりました。

 
Javaコードを参考に作ってみたところ、コンストラクタでIntent生成するところで、エラーになり生成できませんでした。

i = Intent.new(self, FelicaReadActivity.class.name)

NameError: no constructorfor arguments 'jp.gr.java_conf.thinkami.felica_read.FelicaReadActivity,org.jruby.RubyString) on Java::AndroidContent::Intent
available overloads:
(java.lang.String,android.net.Uri)
(android.content.Context, java.lang.Uri)


そのため、引数なしのコンストラクタ + setClassNameで生成するようにしました。
Intentの作り方 - プログラマってこんなかんじ??

p = getPackageName
c = self.java_class.name
i = Intent.new
i.setClassName(p, c)

 
また、上記のリンク先では生成したIntentにFLAG_ACTIVITY_NEW_TASKフラグなどを設定していましたが、AndroidManifestにlaunchModeを指定しても有効だったため、今回は後者の方法で実装しました。

<activity android:label='@string/app_name' android:name='FelicaActivity'  android:launchMode='singleInstance'>


launchModeについては、以下が参考になりました。

 
他、getDefaultAdapterには、「NfcManager」「NfcAdapter」の2クラスにありました。
ソースコードやドキュメントを読むと、NfcAdapterのものはNfcManagerを呼び出すようなHelperだったため、NfcAdapterのものを使用しました。
ちなみに、API16にてNfcAdapter.getDefaultAdapter()の引数なし版は削除されたようですが、引数あり版は有効なようです*2
platform_frameworks_base/core/java/android/nfc/NfcAdapter.java at android-4.3_r2.2 · android/platform_frameworks_base · GitHub

 

FeliCaだけに反応させるため、TechListを指定

フォアグラウンドでのディスパッチ - enableForegroundDispatch - Android Memo Wiki

ただし、JRubyの場合、「NfcF.class.name」ではNfcFクラスの名前が取れなかったため、「NfcF.java_class.name」としてJavaのクラスを取得してからクラス名を取得しました。
CallingJavaFromJRuby · jruby/jruby Wiki · GitHub

 

ForegroundDispatchの無効化を明確にするため、onPauseでisFinishingを使う

ForegroundDispatchとonPauseに注意 - Kazzzの日記

 

JRuby関連で調べたこと

Javaクラスの動的な拡張

Javaのクラスを指定してメソッドを足すことで、拡張できました。
JRubyメモ(工事中)

java_import 'net.kazzz.felica.suica.Suica'

class Suica::History
  def title
    'Suica'
  end
end

 

JavaJRuby間のやりとり

メソッドの指定とか(java_send 等)
CallingJavaFromJRuby · jruby/jruby Wiki · GitHub

 
他にもいろいろとあったため、JRubyの本を買おうか悩んでいたりします。
The Pragmatic Bookshelf | Using JRuby



 

Ruby関連

ヒアドキュメントでの前方空白の削除

今回は空白のインデントなどが不要だったため、stackoverflowに記載されているもののうち、一番簡易的なものを使用しました。

dump += <<-EOF.gsub /^\s+/, ""
EOF


stackoverflowには他にもいろいろと方法が記載されています。
How do I remove leading whitespace chars from Ruby HEREDOC? - Stack Overflow



 

ソース

Githubはこちら。
thinkAmi/RubotoFelicaRead · GitHub


ファイル自体は4つに分けました。なお、エラーハンドリングなどはかなり端折ってあります。

felica_read_activity.rb
メインのActivity。今回はサンプル的なものなので、Activityにいろいろと詰めています。

felica_lib.rb
net.kazzz.felica.lib.FeliCaLibを拡張し、Edy用のサービスコードを追加

suica.rb
net.kazzz.felica.suica.Suicaを拡張し、必要なメソッドを追加

edy.rb
net.kazzz.felica.suica.Suicaを参考にして作成した、Edyからデータをダンプするクラス



 

felica_read_activity.rb
# coding: utf-8

require 'ruboto/widget'
require 'ruboto/util/toast'
require 'suica'
require 'edy'
require 'felica_lib'

ruboto_import_widgets :LinearLayout, :ScrollView, :TextView, :Button

java_import 'net.kazzz.felica.FeliCaTag'
java_import 'net.kazzz.felica.lib.FeliCaLib'
java_import 'net.kazzz.felica.suica.Suica'
java_import 'android.nfc.NfcAdapter'
java_import 'android.nfc.NfcManager'
java_import 'android.nfc.tech.NfcF'
java_import 'android.content.Intent'
java_import 'android.app.PendingIntent'


class FelicaReadActivity
  def onCreate(bundle)
    super

    @main = linear_layout orientation: :vertical do
            @balance = text_view text: 'Felicaを読んで下さい', text_size: 36
            text_view text: '-' * 60
            scroll_view do
              linear_layout orientation: :vertical do
                @history = text_view text: ''
              end
            end
          end
    self.content_view = @main

    # フォアグラウンド・ディスパッチの準備
    @adapter = NfcAdapter.getDefaultAdapter(self)
  end


  def onNewIntent(intent)
    super

    a = intent.getAction
    return unless a == NfcAdapter::ACTION_TECH_DISCOVERED ||
                  a == NfcAdapter::ACTION_TAG_DISCOVERED

    toast '読み込みを開始します'

    # Suicaのシステムコードかどうかで、Suica/Edyの判定をしている(手抜き...)
    tag = intent.getParcelableExtra(NfcAdapter::EXTRA_TAG);
    case to_system_code(NfcF.get(tag).getSystemCode)
    when FeliCaLib::SYSTEMCODE_SUICA
      h, b = dump_history(tag,
                          FeliCaLib::SYSTEMCODE_SUICA,
                          FeliCaLib::SERVICE_SUICA_HISTORY,
                          ->(block_data, activity){ Suica::History.new(block_data, activity) } )

    else
      h, b = dump_history(tag,
                          FeliCaLib::SYSTEMCODE_EDY,
                          FeliCaLib::SERVICE_EDY_HISTORY,
                          ->(block_data, activity){ Edy.new(block_data) } )
    end

    @history.text = h
    @balance.text = '現在の残高: ¥' + b.to_s.gsub(/(?<=\d)(?=(?:\d{3})+(?!\d))/, ',')

    toast '読み込みが完了しました。'
  end


  def onResume
    super

    # 本来なら後者の条件だけで良いはずだが、なぜかインスタンス変数がnilになっているときもあるので、nilチェックを追加
    if @adapter.nil? || !@adapter.isEnabled
      toast 'NFCが無効になっています。'
      finish
      return
    end

    pi = create_pending_intent
    # フォアグラウンド・ディスパッチで反応するのは、FeliCaだけにする
    t = [[NfcF.java_class.name]]
    @adapter.enableForegroundDispatch(self, pi, nil, t)
  end


  def onPause
    super
    @adapter.disableForegroundDispatch(self) if isFinishing
  end

  def create_pending_intent
    p = getPackageName
    c = self.java_class.name
    i = Intent.new
    # AndroidManifestにlaunchModeを記載したので、ここではフラグの指定は不要みたい
    i.setClassName(p, c)

    PendingIntent.getActivity(self, 0, i, 0)
  end


  def to_system_code(bytes)
    bytes.nil? ? '' : bytes.map{ |byte| "%02X" % (byte & 0xff) }.join.to_i
  end


  def dump_history(tag, system_code, service_code, func)
    felica_tag = FeliCaTag.new(tag)
    felica_tag.polling(system_code)

    sc = FeliCaLib::ServiceCode.new(service_code)
    addr = 0
    result = felica_tag.readWithoutEncryption(sc, addr)

    dump = ''
    while !result.nil? && result.getStatusFlag1 == 0
      target = func.call(result.getBlockData, self)

      dump += <<-EOF.gsub /^\s+/, ""
        #{target.title} 履歴 No. #{(addr + 1).to_s}
        ---------
        #{target.to_string}
        ---------------------------------------

      EOF

      balance = target.balance if balance.nil?
      addr += 1

      begin
        result = felica_tag.readWithoutEncryption(sc, addr)
      rescue
        dump += <<-EOF.gsub /^\s+/, ""
        ---------------------------------------
        読込が中断されました
        ---------------------------------------
        EOF

        return dump, balance
      end
    end

    return dump, balance
  end
end

 

felica_lib.rb
java_import 'net.kazzz.felica.lib.FeliCaLib'

class FeliCaLib
  # サービスコード
  # 参照: http://jennychan.web.fc2.com/format/edy.html
  SERVICE_EDY_HISTORY = '0x170F'.to_i(16)
end

 

suica.rb
java_import 'net.kazzz.felica.suica.Suica'

# Javaのクラス名を指定して、あとはメソッドを書いていけば、動的に拡張できる
class Suica::History
  # toStringメソッドは、JRubyが自動的に to_string メソッドへと変換してくれるので、実装しなくてよい

  def title
    'Suica'
  end

  def balance
    b = self.getBalance
    b.to_s.gsub(/(?<=\d)(?=(?:\d{3})+(?!\d))/, ',')
  end
end

 

edy.rb
# coding: utf-8

require 'date'

java_import 'net.kazzz.util.Util'

class Edy

  attr_reader :title
  def initialize(data)
    @data = data
    @title = 'Edy'
  end

  def to_string
    <<-EOF.gsub /^\s+/, ""
      区分: #{category}
      連番: #{sequence}
      日時: #{datetime}
      金額: #{to_currency(amount)}
      残高: #{to_currency(balance)}
    EOF
  end

  def category
    case to_hex_string(@data[0])
    when '0x02'; 'チャージ'
    when '0x04'; 'Edyギフト'
    when '0x20'; '支払い'
    else; '???'
    end
  end

  def sequence
    Util.toInt(@data[1], @data[2], @data[3]).to_s
  end

  def datetime
    dt10 = Util.toInt(@data[4], @data[5], @data[6], @data[7])

    # 2進数に直したときの、上位15bitが日付、下位17bitが時刻
    # 上記は10進数なので、2進数へと変換する
    dt2 = dt10.to_s(2).to_i(2)

    # 日付の算出
    # 先頭から15bit残すので、右にある17bitをシフトして消す
    elapsed_days = dt2 >> 17
    # elapsed_days = dt2.to_i(2) >> 17

    # 時刻の算出
    # 後ろから17bitだけを残すので、不要なbitを消すために、論理積を取る
    elapsed_time = dt2 & 0b00000000000000011111111111111111

    # 2000/01/01からの経過日時の算出
    d = (Date.new 2000, 1, 1) + elapsed_days
    dt = Time.local(d.year, d.month, d.day, 0, 0, 0, 0) + elapsed_time
    dt.strftime('%Y/%m/%d %H:%m:%S')
  end

  def amount
    Util.toInt(@data[8], @data[9], @data[10], @data[11])
  end

  def balance
    Util.toInt(@data[12], @data[13], @data[14], @data[15])
  end

  def to_hex_string(byte)
    "%#02x" % (byte & 0xff)
  end

  def to_currency(value)
    '' + value.to_s.gsub(/(?<=\d)(?=(?:\d{3})+(?!\d))/, ',')
  end
end

*1:fc2とnijnaに同じデータがありますが、どちらが正なのかは分かりません。とりあえずat-ninjaのを参照しました

*2:Githubソースコードを参照しました。Best effortなミラーなようですが、ソースを見るだけの用途だったら十分な気がします