10.08.2020

Android & KotlinでSuicaの履歴を読み取るメモ

Suica を扱う機会があったので実装メモ。
下記は Android Studio の Empty Activity で作成。

build.gradle(Project)

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
  ext.kotlin_version = "1.3.72"
  repositories {
    google()
    jcenter()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:4.0.1"
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

    // NOTE: Do not place your application dependencies here; they belong
    // in the individual module build.gradle files
  }
}

allprojects {
  repositories {
    google()
    jcenter()
  }
}

task clean(type: Delete) {
  delete rootProject.buildDir
}

build.gradle(Module)

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
  compileSdkVersion 30
  buildToolsVersion "30.0.2"

  defaultConfig {
    applicationId "com.example.androidtest"
    minSdkVersion 29
    targetSdkVersion 30
    versionCode 1
    versionName "1.0"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }
}

dependencies {
  implementation fileTree(dir: "libs", include: ["*.jar"])
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
  implementation 'androidx.core:core-ktx:1.1.0'
  implementation 'androidx.appcompat:appcompat:1.1.0'
  implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
  testImplementation 'junit:junit:4.12'
  androidTestImplementation 'androidx.test.ext:junit:1.1.1'
  androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.example.androidtest">

  <uses-permission android:name="android.permission.NFC" />

  <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
    <activity android:name=".MainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>

</manifest>

AndroidManifest.xml<uses-permission android:name="android.permission.NFC" /> で NFC の権限を加える以外はデフォルト設定。

MainActivity.kt

package com.example.androidtest

import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.NfcF
import android.os.Bundle
import android.util.Log
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.LinearLayoutCompat


class MainActivity : AppCompatActivity() {
  private val m_nfc_adapter by lazy { NfcAdapter.getDefaultAdapter(this) }

  private var m_result: List<List<Byte>>? = null

  private val type = mapOf(
    3 to "精算機",
    4 to "携帯型端末",
    5 to "車載端末",
    7 to "券売機",
    8 to "券売機",
    9 to "入金機",
    18 to "券売機",
    20 to "券売機等",
    21 to "券売機等",
    22 to "改札機",
    23 to "簡易改札機",
    24 to "窓口端末",
    25 to "窓口端末",
    26 to "改札端末",
    27 to "携帯電話",
    28 to "乗継精算機",
    29 to "連絡改札機",
    31 to "簡易入金機",
    70 to "VIEW ALTTE",
    72 to "VIEW ALTTE",
    199 to "物販端末",
    200 to "自販機"
  )

  private val mode = mapOf(
    1 to "運賃支払(改札出場)",
    2 to "チャージ",
    3 to "券購(磁気券購入)",
    4 to "精算",
    5 to "精算 (入場精算)",
    6 to "窓出 (改札窓口処理)",
    7 to "新規 (新規発行)",
    8 to "控除 (窓口控除)",
    13 to "バス (PiTaPa系)",
    15 to "バス (IruCa系)",
    17 to "再発 (再発行処理)",
    19 to "支払 (新幹線利用)",
    20 to "入A (入場時オートチャージ)",
    21 to "出A (出場時オートチャージ)",
    31 to "入金 (バスチャージ)",
    35 to "券購 (バス路面電車企画券購入)",
    70 to "物販",
    72 to "特典 (特典チャージ)",
    73 to "入金 (レジ入金)",
    74 to "物販取消",
    75 to "入物 (入場物販)",
    198 to "物現 (現金併用物販)",
    203 to "入物 (入場現金併用物販)",
    132 to "精算 (他社精算)",
    133 to "精算 (他社入場精算)"
  )

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
  }

  override fun onResume() {
    super.onResume()

    val lv = ListView(this)
    setContentView(lv)

    m_result?.let {
      val m_array_adapter = ArrayAdapter(
        this,
        android.R.layout.simple_list_item_1,
        m_result!!.map { row ->
          var i = 0
          row.joinToString("|") { column ->
            if (i == 0) {
              i += 1
              return@joinToString type["%02x".format(column).toInt(16)]!!
            }

            if (i == 1) {
              i += 1
              return@joinToString mode["%02x".format(column).toInt(16)]!!
            }

            i += 1
            return@joinToString "%02x".format(column)
          }
        }
      )
      lv.adapter = m_array_adapter
    }

    m_nfc_adapter.enableForegroundDispatch(
      this,
      PendingIntent.getActivity(this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), 0),
      arrayOf(IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).apply { this.addDataType("*/*") }),
      arrayOf(arrayOf(NfcF::class.java.name))
    )
  }

  override fun onNewIntent(intent: Intent?) {
    super.onNewIntent(intent)

    if (intent == null) {
      return
    }

    m_result = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)?.let { read(it) }
    Log.d("MainActivity", "$m_result")
  }
}

FeliCa Library(Suica)を参考に、エントリ種別と端末種別を HashMap で定義。
onResumeenableForegroundDispatch を定義して NFC の読み取り状態開始。
読み取ると onNewIntent が呼ばれ、再び onResume を繰り返す。

Polling.kt

package com.example.androidtest

enum class SystemCode(val value: ByteArray) {
  railway(value = byteArrayOf(0x00.toByte(), 0x03.toByte())), // Suica/PASMOなどの鉄道系
}

enum class RequestCode(val value: Byte) {
  none(value = 0x00), // 要求無し
  system_code(value = 0x01), // システムコード要求
  communication_ability(value = 0x02) // 通信性能要求
}

fun getIDm(response: ByteArray) = response.copyOfRange(fromIndex = 2, toIndex = 10).toTypedArray()

ReadWithoutEncryption.kt

package com.example.androidtest

import android.util.Log

enum class ServiceCode(val value: ByteArray) {
  attributes(byteArrayOf(0x00.toByte(), 0x8B.toByte())),
  history(byteArrayOf(0x09.toByte(), 0x0F.toByte())),
}

enum class BlockListSize(val value: Int) {
  three_byte(0),
  two_byte(1),
}

enum class BlockListAccessMode(val value: Int) {
  unuse_parse_service(0),
  use_parse_service(1),
}

fun isSuccessful(response: ByteArray): Boolean {
  val status_flag_1 = response[10]
  val status_flag_2 = response[11]

  return status_flag_1.toInt() == 0x00 && status_flag_2.toInt() == 0x00
}

fun getData(response: ByteArray) = response.slice(13 until response.size).chunked(16)

NfcFReader.kt

package com.example.androidtest

import android.nfc.Tag
import android.nfc.tech.NfcF
import android.util.Log


fun read(tag: Tag): List<List<Byte>>? {
  val nfcf = NfcF.get(tag)

  try {
    nfcf.connect()

    val pol_res = nfcf.transceive(byteArrayOf(
      0x06.toByte(), // パケットサイズ(6byte固定)
      0x00.toByte(), // コマンドコード
      SystemCode.railway.value[0], // システムコード
      SystemCode.railway.value[1], // システムコード
      RequestCode.system_code.value, // リクエストコード
      0x0F.toByte() // タイムスロット(固定値)
    ))


    val idm = getIDm(pol_res)

    fun createBlockListElement(block_number: Int) = arrayOf((
      (BlockListSize.two_byte.value shl 7) and // b7(長さ)
        (BlockListAccessMode.unuse_parse_service.value shl 4) and // b6-b4(アクセスモード)
        0 // b3-b0(サービスコードリスト順番)
      ).toByte(), // D0
      block_number.toByte(), // D1
      0.toByte() // D2
    )

    val blocks = (0 until 10).map {
      createBlockListElement(it)
    }.toTypedArray()

    val p = arrayListOf<Byte>()
    p += (14 + (blocks.size * 3)).toByte() // パケットサイズ
    p += 0x06.toByte() // コマンドコード(固定値)
    p.addAll(idm)
    p += 0x01.toByte() // サービス数(履歴取得しかしないので1つ)
    p.addAll(elements = ServiceCode.history.value.reversed()) // サービスコードリスト(リトルエンディアン)
    p += blocks.size.toByte() // ブロック数
    p.addAll(blocks.flatten().reversed())

    val rwe_res = nfcf.transceive(p.toByteArray())
    if (!isSuccessful(rwe_res)) {
      throw Exception(rwe_res.joinToString("") { "%02x".format(it) })
    }


    nfcf.close()

    return getData(rwe_res)
  } catch (e: Exception) {
    Log.e("NfcFReader", "cannot read nfc: '$e'")

    if (nfcf.isConnected) {
      nfcf.close()
    }

    return null
  }
}

FeliCa カード ユーザーズマニュアル 抜粋版の下記を参考に実装。

  • 4.2 ブロックへのアクセス
  • 4.4.2 Polling
  • 4.4.5 Read Without Encryption

Polling 部分はそのまま必要な情報を投げて、帰ってきた結果から IDm を抜き取る。
Read Without Encryption 部分はブロックリストエレメントとか言うのを作るのがちょっとめんどくさい。
b7 の長さを 2 バイトにした時、ブロック番号の先頭(D2 部分?)は 0 バイトを入れておかないといけないっぽい?
そしてブロック群はリトルエンディアンなので配列をひっくり返してあげる。

ちなみにサービスは複数指定できるっぽくてサービス数とサービスコードリスト、ブロックリストエレメントのサービスコードリスト順番を上手く設定すれば取れそう。

実行結果

実行結果

Suica から直接取得できるのは最新 20 件分で、それ以上はサーバ側らしい。
また、1 回で取得できるブロックが 11 個で(同時読み出し可能なブロック数の最大値がある?)2 回に分けて取りに行く必要がある。

iPhone と Apple Watch の Suica/PASMO の読み取りできたけど、判定がシビアだった(Android 端末側の問題?)。