インテント (Intent) とインテントフィルタ (intent-filter)

概要

Android の基礎的な仕組みである Intent について整理します。 下記のページの内容を私なりに整理したものです。

developer.android.com

解説

コンポーネント

Intent はコンポーネント間の通知に使われる。 Intent の日本語訳は、意図や意思。 Intent はコンポーネント間を伝わる意思ということ。

Android におけるコンポーネントは下記の 4 種類 (アプリケーションの基礎 | Android Developers 参照)。

  • アクティビティ (Activity)
  • サービス (Service)
  • ブロードキャストレシーバー (BroadcastReceiver)
  • コンテンツプロバイダー (ContentProvider)

ContentProvider を除く 3 種類のコンポーネントは Intent によりアクティベート(起動、機能を有効化)される。 本記事では便宜上、Intent によりアクティベートされる 3 種類をコンポーネント、アクティベートを起動と呼ぶ。

コンポーネントを起動する

コンポーネント (Activity, Service, BroadcastReceiver) を起動するメソッドは下記のとおり。

  • Activity
    • startActivity
    • startActivityForResult : Activity から結果を受け取りたい場合に使用
  • Service
    • startService
    • bindService : Service と接続して通信したい場合に使用
  • BroadcastReceiver
    • sendBroadcast
    • sendOrderedBroadcast
    • sendStickyBroadcast

各メソッドは引数に Intent を受け取る。 Intent によって起動するコンポーネントを指定する。

Intent オブジェクトの生成

Intent クラスのコンストラクタは下記の 6 つ。

  • Intent()
    • 万能
  • Intent(Intent o)
    • Intent オブジェクトのコピー
  • Intent(String action)
  • Intent(String action, Uri uri)
  • Intent(Context packageContext, Class<?> cls)
  • Intent(String action, Uri uri, Context packageContext, Class<?> cls)

最初の 2 つを除くコンストラクタを分類すると 3 通りになる。

  • Context packageContext, Class<?> cls を引数にとるコンストラクタ
    • 明示的 Intent として生成する
  • String action を引数にとるコンストラクタ
    • アクションを指定して生成
  • Uri uri を引数にとるコンストラクタ
    • データの URI を指定して生成

明示的 Intent としての指定、アクション指定、データの URI 指定はコンストラクタ以外でも指定可能である。

Intent に指定する情報

  • コンポーネント
    • クラスの完全修飾名 (例えば、"com.example.project" + "PingPongActivity")
    • コンストラクタの cls で指定
    • setComponent、setClass、setClassName メソッドで指定
    • 指定すると明示的 Intent として扱われる
  • アクション
    • 実行する内容を意味する文字列 ("パッケージ名.アクション名" とする。例えば、"com.example.project.ACTION_FLY")
    • 一般的なアクションは定義済み (Intent | Android Developers 参照)
    • コンストラクタの action で指定
    • setAction メソッドで指定
  • データ
    • データの参照先 URI
      • アクション毎に指定できる URI が決まっている (どこみればわかる?)
    • コンストラクタの uri で指定
    • setData メソッドで指定
    • データには MIME タイプを指定する場合がある
      • データの MIME タイプを指定する場合には setType を使う
      • URIMIME タイプの両方を指定する場合は必ず setDataAndType を使う (setData と setType を別々に使うのではなく)
      • MIME タイプは URI から推測されて自動的に指定される場合がある
  • カテゴリ
    • コンポーネントの種類を表す文字列 ("パッケージ名.カテゴリ名" とする。例えば、"android.intent.category.LAUNCHER")
    • 一般的なカテゴリは定義済み (Intent | Android Developers 参照)
    • addCategory メソッドで指定
    • startActivity, startActivityForResult では、Intent に "android.intent.category.DEFAULT" が自動的に指定される
  • エクストラ
    • 任意の追加情報 (キーと値のペア)
      • アクション毎に指定できるキー(と値の型)が決まっている
      • キーは "パッケージ名.キー名" とする (例えば、"com.example.project.EXTRA_INTO_RIVER")
    • 一般的なキーは定義済み (Intent | Android Developers 参照)
    • putExtra, putExtras, put[Type]ArrayListExtra メソッドで指定
  • フラグ

Intent のフラグ

startActivity, startActivityForResult に渡す Intent で指定するフラグ

  • FLAG_ACTIVITY_BROUGHT_TO_FRONT
  • FLAG_ACTIVITY_CLEAR_TASK
  • FLAG_ACTIVITY_CLEAR_TOP
  • FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET
  • FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
  • FLAG_ACTIVITY_FORWARD_RESULT
  • FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
  • FLAG_ACTIVITY_MULTIPLE_TASK
  • FLAG_ACTIVITY_NEW_DOCUMENT
  • FLAG_ACTIVITY_NEW_TASK
  • FLAG_ACTIVITY_NO_ANIMATION
  • FLAG_ACTIVITY_NO_HISTORY
  • FLAG_ACTIVITY_NO_USER_ACTION
  • FLAG_ACTIVITY_PREVIOUS_IS_TOP
  • FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
  • FLAG_ACTIVITY_REORDER_TO_FRONT
  • FLAG_ACTIVITY_SINGLE_TOP
  • FLAG_ACTIVITY_TASK_ON_HOME

sendBroadcast, sendOrderedBroadcast, sendStickyBroadcast に渡す Intent で指定するフラグ

  • FLAG_RECEIVER_REGISTERED_ONLY

共通のフラグ?

  • FLAG_GRANT_READ_URI_PERMISSION
  • FLAG_GRANT_WRITE_URI_PERMISSION
  • FLAG_GRANT_PERSISTABLE_URI_PERMISSION
  • FLAG_GRANT_PREFIX_URI_PERMISSION
  • FLAG_DEBUG_LOG_RESOLUTION
  • FLAG_FROM_BACKGROUND

Intent の種類

明示的 Intent

コンポーネントが指定された Intent は明示的 Intent となる。 明示的 Intent では指定したコンポーネントが起動される。

暗黙的 Intent

コンポーネントが指定されていない Intent は暗黙的 Intent となる。 Intent に指定したアクション、データ、カテゴリと各コンポーネントの intent-filter でマッチングを行い、起動するコンポーネントを実行時に決定する。 マッチングを行った結果、コンポーネントが見つからない場合や複数見つかる場合がある。

Service では暗黙的 Intent を使用せず、intent-filter を定義しないこと。 Service で暗黙的 Intent と intent-filter を使用すると、ユーザーは使用される Service を把握できず、セキュリティ上の危険を伴う。 bindService メソッドに暗黙的 Intent を渡すと例外がスローされる (API Level ≧ 21)。

コンポーネントが見つからない場合

startActivity, startActivityForResult を呼び出し、コンポーネントが見つからなかった場合にはアプリがクラッシュする。 クラッシュしないようにするために resolveActivity メソッドにより、Intent に応答できるコンポーネントの存在を確認する。

val sendIntent = Intent(Intent.ACTION_SEND)
...
if (sendIntent.resolveActivity(packageManager) != null) {
    startActivity(sendIntent)
}

Service は暗黙的 Intent では使用されず、BroadcastReceiver ではいずれの BroadcastReceiver も通知を受け取らない。

コンポーネントが複数見つかる場合

startActivity, startActivityForResult を呼び出し、コンポーネントが複数見つかった場合には下記のいずれかの振る舞いをする。

  • アプリチューザ (アプリ選択ダイアログ) が表示され、ユーザーに Activity の選択を促す
  • ユーザーがアクションに対して選択したデフォルトの Activity が選択される

アクションに対するデフォルトの Activity が選択されている場合でも、強制的にアプリチューザを表示することができる。

val sendIntent = Intent(Intent.ACTION_SEND)
...
val chooser = Intent.createChooser(sendIntent, "Chooser Title");
if (sendIntent.resolveActivity(packageManager) != null) {
    startActivity(chooser)
}

Service は暗黙的 Intent では使用されず、BroadcastReceiver ではマッチングに成功した全ての BroadcastReceiver が通知を受けとる。

マッチングの仕組み

AndroidManifest.xml の各コンポーネント(activity, receiver 要素)に intent-filter 要素を追加することで、各コンポーネントが受信する Intent を指定する。 BroadcastReceiver の intent-filter は Context クラスの registerReceiver, unregisterReceiver メソッドで動的に変更できる。 動的に変更することでアプリ実行中のみ受信できる。 Service は暗黙的 Intent では使用しないため、intent-filter 要素を追加しないこと。

intent-filter 要素は各コンポーネント(activity, receiver 要素)に複数指定できる。 複数指定した場合は or 条件として、Intent がいずれかの intent-filter にマッチした場合にコンポーネントが起動される。

intent-filter 要素には、action, category, data 要素を含むことができる。 各要素は intent-filter 要素内に複数指定できる。

<activity android:name="PingPongActivity">
    <intent-filter>
        <action android:name="android.intent.action.SEND"/>
        <action android:name="com.example.project.ACTION_FLY"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.LAUNCHER" />
        <data android:mimeType="image/*"/>
        <data android:mimeType="video/*" android:scheme="http"/>
        <data android:scheme="content" android:host="project.example.com" android:port="200" android:path="folder/subfolder/etc"/>
    </intent-filter>
</activity>

action, category, data 要素のマッチングは複雑であり、文章で説明すると曖昧になるため擬似コードでの整理を試みる。

intent match filter ::=
(
    (
        intent.action == filter.action[0]
        or
        intent.action == filter.action[1]
        or
        ...
    )
    and
    intent.category[0] match filter.category
    and
    intent.category[1] match filter.category
    and
    ...
    and
    (
        intent.data match filter.data[0]
        or
        intent.data match filter.data[1]
        or
        ...
    )
)

intent.category[i] match filter.category ::=
(
    intent.category[i] == filter.category[0]
    or
    intent.category[i] == filter.category[1]
    or
    ...
)

intent.data match filter.data[i] ::=
(
    (
        intent.data.mimeType == null and filter.data[i].mimeType == null
        or
        intent.data.mimeType != null and intent.data.mimeType match filter.data[i].mimeType
    )
    and
    (
        intent.data.uri == null and filter.data[i].uri == null
        or
        intent.data.uri != null and intent.data.uri match filter.data[i].uri
    )
)

intent.data.uri match filter.data[i].uri ::=
(
    (filter.data[i].uri.scheme == null or intent.data.uri.scheme == filter.data[i].uri.scheme)
    and
    (filter.data[i].uri.host == null or intent.data.uri.host == filter.data[i].uri.host)
    and
    (filter.data[i].uri.port == null or intent.data.uri.port == filter.data[i].uri.port)
    and
    (filter.data[i].uri.path == null or intent.data.uri.path match filter.data[i].uri.path)
)

intent-filter の補足事項。

data 要素の path, mimeType 属性では、* (ワイルドカード) 指定が可能。 部分一致が可能なので、上記の擬似コードでは == ではなく match としている。

data 要素の scheme, host, port, path 属性は下記に示す組み合わせのみ有効。

data に mimeType 属性のみが指定されている場合、scheme 属性に content, file が指定されているとして扱われる。 つまり、下記の 2 つは同じ。

<data mimeType="text/plain">
<data mimeType="text/plain" scheme="content">
<data mimeType="text/plain" scheme="file">

Intent の補足事項。

startActivity, startActivityForResult では Intent に CATEGORY_DEFAULT が追加されるので、 activity の intent-filter の category に CATEGORY_DEFAULT を指定する必要がある。

Intent で MIME タイプ (mimeType) を指定していなくても、data の URI から推測されて指定されることがある。

コンポーネントを探す

Intent を受け入れることができるコンポーネントは PackageManager クラスの下記のメソッドで探せる。

  • queryIntentActivities(Intent intent, int flags)
  • queryIntentServices(Intent intent, int flags)
  • queryBroadcastReceivers(Intent intent, int flags)

Intent に応答できる最適なコンポーネントは PackageManager クラスの下記のメソッドで探せる。 (最適とは?)

  • resolveActivity (Intent intent, int flags)
  • resolveService (Intent intent, int flags)

PendingIntent

別のアプリケーションに Intent を実行させる場合には PendingIntent を使う。 別のアプリケーションの代表例は、通知、ウィジェット、アラーム。

PendingIntent は下記のメソッドにより取得する。

  • Activity
    • PendingIntent.getActivity
  • Service
    • PendingIntent.getService
  • Broadcast
    • PendingIntent.getBroadcast

使用例。

val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notification = NotificationCompat.Builder(context).apply {
    setContentIntent(pendingIntent)
    ...
}.build()

補足

マッチングの仕組みの導出

コードでの整理に辿り着くまでの過程。

アクションのテストから。

Intent で指定されたアクションが、フィルタにリストされたアクションのいずれかに一致する必要があります。(*1)

フィルタのリストにアクションが 1 つもない場合は、インテントに一致するものがないため、すべてのインテントがテストに失敗します。(*2)

Intent がアクションを指定していない場合は、テストに合格します(フィルタに少なくとも 1 つのアクションが含まれている必要があります)。(*3)

filter.action intent.action match 引用との対応
null null false *3, 実際に試すとダメ
null not null false *2
not null null false *1
not null not null いずれかに一致 *1

カテゴリのテストから。

インテントがカテゴリのテストに合格するには、Intent 内のすべてのカテゴリが、フィルタ内のカテゴリに一致する必要があります。

カテゴリのないインテントは、フィルタで宣言されているカテゴリに関係なく、常にこのテストに合格することになります。

(intent.category[0] ...) and (intent.category[1] ...)。

インテント フィルタでは、Intent で指定されたカテゴリよりも多くのカテゴリが宣言されている場合もあるため、すべてが Intent に一致しなくてもテストには合格します。

(intent.category[0] == filter.category[0]) or (intent.category[0] == filter.category[1])。

検証。

filter.category intent.category match
null null true
null A false
A null true
A A true
A A,B false

データのテストから。

スキームが指定されていない場合、ホストは無視されます。

ホストが指定されていない場合、ポートは無視されます。

スキームとホストの両方が指定されていない場合、パスは無視されます。

全ての組み合わせを網羅して、無視される部分を () で囲むと

scheme, host, port, path
scheme, host, port
scheme, host, path
scheme, host
scheme, (port), path
scheme, (port)
scheme, path
scheme
(host), (port), (path)
(host), (port)
(host), (path)
(host)
(port), (path)
(port)
(path)

無視される場合は指定されていないことになると解釈している。 無視される場合を整理すると

scheme, host, port, path
scheme, host, port
scheme, host, path
scheme, host
scheme, path
scheme

インテントURI をフィルタの URI 仕様に比較するときは、フィルタに含まれる URI の一部でのみ比較されます。

フィルタでスキームのみが指定されている場合、そのスキームを持つすべての URI がフィルタに一致します。

フィルタでスキームと認証局が指定されていて、パスが指定されていない場合、パスにかかわらず同じスキームと認証局を持つすべての URI がフィルタを通過します。

フィルタでスキーム、認証局、パスが指定されている場合、同じスキーム、認証局、パスを持つ URI のみがフィルタを通過します。

intent.data = content::example.com:200/folder/subfolder として検証。

scheme host port path match
content example.com 200 /folder/subfolder true
content example.com 200 null true
content example.com null /folder/subfolder true
content example.com null null true
content null 200 /folder/subfolder IDE error
content null 200 null IDE error
content null null /folder/subfolder IDE error
content null null null true
null example.com 200 /folder/subfolder IDE error
null example.com 200 null IDE error
null example.com null /folder/subfolder IDE error
null example.com null null IDE error
null null 200 /folder/subfolder IDE error
null null 200 null IDE error
null null null /folder/subfolder IDE error

先の整理した内容と比較すると scheme, path の組み合わせのみ期待と異なる。

scheme, host, port, path
scheme, host, port
scheme, host, path
scheme, host
scheme, path // 指定できない
scheme

intent-filter で指定した属性が 1 つでも一致しなかった場合には、data 要素全体として一致しなかったことになる。

intent.data = content::example.com:200/folder/subfolder として検証。

scheme host port path match
content example.com 200 /mismatch false
content example.com 80 /folder/subfolder false
content mismatch.com 200 /folder/subfolder false
http example.com 200 /folder/subfolder false

URIMIME タイプも含まないインテントは、フィルタで URIMIME タイプが指定されていない場合のみテストをパスします。

URI を含んでいて MIME タイプを含んでいないインテント(明示的にも含まれておらず、URI からも推測できない)場合は、URI がフィルタの URI 形式に一致し、フィルタが MIME タイプを指定していない場合のみテストをパスします。

MIME タイプを含んでいて、URI を含んでいないインテントは、フィルタのリストに同じ MIME タイプがあり、URI 形式が指定されていない場合のみテストをパスします。

URI と MINE タイプの両方を含む(明示的か、URI からの推測)インテントは、MIME タイプがフィルタのリストにあるタイプに一致した場合のみ、テストの MIME タイプのパートをパスします。 テストの URI のパートは、URI がフィルタの URI に一致するか、content: URI か file: URI があって URI が指定されていない場合にパスできます。つまり、フィルタにリストに MIME タイプのみがある場合、コンポーネントは content: データと file: データをサポートすると推定されます。

次の条件で検証。

結果。

filter.mimeType filter.uri intent.mimeType intent.uri match
not null not null not null not null 共に一致すれば
not null not null not null null false
not null not null null not null false
not null null not null not null false
null not null not null not null false

更に別の条件で検証。

  • intent
    • uri = "content://example.com:200/folder/subfolder"
    • mimeType = "text/plain"
  • filter
    • uri = scheme="content", host="example.com", port="200", path="/folder/subfolder"
    • mimeType = "text/plain"

結果。

filter.mimeType filter.uri intent.mimeType intent.uri match
not null not null not null not null 共に一致すれば
not null not null not null null false
not null not null null not null false
not null null not null not null true
null not null not null not null false

filter.uri を省略した場合は、scheme="content" と scheme="file" が指定されている扱いとなる。

Android で SQLite を使う

概要

AndroidSQLite を使う方法です。 サンプルにはデータの参照、挿入、トランザクションを含みます。

参考情報

サンプルコード

解説

  • Database.kt
    • Database : SQLiteOpenHelper を実装して汎用的な処理を行うクラス
  • SQLiteDatabaseExtensions.kt : update, insert メソッドの引数を省略できるようにするための拡張関数
  • SampleDB.kt
    • SampleDB : サンプルデータベースの定義
    • SampleDB.Dao : サンプルデータベースが持つ DAO の一覧
  • SampleTableDao.kt
    • SampleTableDao : DAO (Data Access Object)
  • Client.kt
    • Client : 上記のクラスを使用して処理を行うクラス

AndroidSQLite を使うには SQLiteOpenHelper を使用する。 Database にて SQLiteOpenHelper を使用している。 データベースファイルが存在しない場合には onCreate メソッドが呼ばれるため、onCreate メソッドが呼ばれたらテーブルを作成する。 テーブルの作成は SampleDB.Dao に処理を委譲して実現している。 SampleDB.Dao は SampleTableDao に処理を委譲する。 複数の DAO がある場合には、複数の DAO に処理を委譲する。 データベースのバージョンが変更された場合には onUpgrade メソッドが呼ばれるが、今回のサンプルでは未実装。

// Database
private val helper = object : SQLiteOpenHelper(context, def.name, def.cursorFactory, def.version) {

    override fun onCreate(db: SQLiteDatabase) {
        def.create(db).onCreate()
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
}

CRUD 操作は、SQLiteOpenHelper から readableDatabase と writableDatabase を取得して行う。 readableDatabase と writableDatabase は共に SQLiteDatabase のインスタンス。 SQLiteDatabase は query メソッドや insert メソッドを持つ。 query メソッドや insert メソッドの呼び出しは、SampleTableDao の仕事。 query メソッドと insert メソッドは多くの引数を持ち、全ての引数を指定する必要のない場合がある。 引数を省略できるようにするために SQLiteDatabaseExtensions.kt で拡張関数を定義している。

// Database
helper.readableDatabase.use {
    ...
} // use 関数により readableDatabase は close() される

helper.writableDatabase.use {
    ...
} // use 関数により writableDatabase は close() される
// SampleTableDao
fun insert(seqId: Int, time: Int, type: Int) {
    val values = ContentValues().apply {
        put("seq_id", seqId)
        put("time", time)
        put("type", type)
    }
    db.insert(TABLE_NAME, values) // SQLiteDatabaseExtensions.kt で定義された拡張関数
}

Client では CRUD 操作を行う関数を定義し、Database の update メソッドに渡す。 関数のブロックがトランザクションに対応する。 例外を発生させることなく関数を終えた場合にはコミットされ、例外が発生した場合にはロールバックされる。 トランザクション管理は Database で行っている。 update メソッドでは、関数実行前に beginTransaction() を実行、実行後に endTransaction() を実行している。 例外が発生しなかった場合には setTransactionSuccessful() を実行してコミットされるようにする。

// Database
// it は SQLiteDatabase のインスタンス
it.beginTransaction()
try {
    val result = procedure(def.create(it))
    it.setTransactionSuccessful()
    return result
}
finally {
    it.endTransaction()
}

update メソッドが受け取る関数では、引数に SampleDB.Dao を受け取る。 SampleDB.Dao が保持する SampleTableDao に処理を委譲することで CRUD 操作が行われる。 テーブルが増えた場合には SampleDB.Dao に各テーブルの DAO を加える。

// Client
db.update { // トランザクション開始
    // it は SampleDB.Dao のインスタンス
    val seqId = it.sampleTable.nextSeqId()
    it.sampleTable.insert(seqId, time, type)
} // トランザクション終了 (例外が発生しなければコミットされる)

Kotlin 補足

Kotlin は全く知らないという方のための補足。

use 関数

下記のコードは同じこと。

// Kotlin
helper.writableDatabase.use {
    it.beginTransaction()
    ...
}
// Java
try (SQLiteDatabase it = helper.getWritableDatabase()) {
    it.beginTransaction()
    ...
}

SQLite のデータをコマンドプロンプトから参照する

概要

AndroidSQLite を使う際に、コマンドプロンプトから SQL 文を実行してデータを確認したい場合があります。 実機に sqlite3 がインストールされていない場合の実行方法について書きます。 但し、実機で base64 コマンドを実行できる場合の方法です。

確認環境

参考情報

解説

AndroidSQLite を使っていれば、コマンドプロンプトから SQL 文を実行してデータを確認したくなるもの。 しかし、実機に sqlite3 コマンドがインストールされていない。 sqlite3 を実機にインストールするには root 権限が必要だが、root 取得はしたくない。 その場合には、データベースファイルを PC にコピーする。

コピーをするために下記のコマンドを実行する。

$ adb shell run-as <アプリのパッケージ名> toybox base64 databases/<データベースファイル名> | base64 -D > <データベースファイル名>
  1. adb shell コマンドで、実機において shell を実行する。実行するコマンドは run-as <アプリのパッケージ名> toybox base64 databases/<データベースファイル名>
  2. run-as コマンドは、アプリのユーザーとしてコマンドを実行する。デバッグモードでのみ機能するらしい。
  3. toybox は様々なコマンドを集約したバイナリ。toybox に含まれる様々なコマンドの一つとして base64 コマンドがある。
  4. base64 コマンドはバイナリデータを文字データに変換する。
  5. run-as から | (パイプ) の前までが実機側で実行され、| (パイプ) 以降は PC 側で実行される。| (パイプ) によりデータを受け渡す。
  6. base64 -D により、文字データを再びバイナリデータに変換する。
  7. > <データベースファイル名> により、ファイルに保存する。

データベースファイルを取得した後は PC で操作する。

$ sqlite3 <データベースファイル名>
sqlite> .table
...

toybox base64 を試す前に cat で試したが、データが一部変わってしまったので文字データに変換する方法を探したところ、 Xperia Z5 には toybox がインストールされていたので問題を解決できた。

ADB (Android Debug Bridge) のプロトコル 後編

概要

ADB の Java クライアントを試作してみようと取り組んでみたら深みにはまっていったお話。 とても長いので前後編に分けました。

雑談

nosix.hatenablog.com

幸せになれたと思ったのに

コマンドを実行できるようになった。 しかし、再度接続を試みる際に署名を送信しても認証に失敗する。 署名による認証に失敗すると公開鍵を再送信することになり、ユーザーに確認を求めるダイアログが表示される。 これは煩わしい。 ダイアログ表示はなくしたい。 署名で認証せねば。 ここから調べ物の旅が始まる。

署名を生成する処理を JavaAPI を使用して Kotlin でコードを書くと次のようになる。 token はサーバーから送信されたトークン。 NONE はダイジェストを使用しない指定。 (標準アルゴリズム名のドキュメント)

fun sign(token: ByteArray, key: PrivateKey): ByteArray {
    Signature.getInstance("NONEwithRSA").run {
        initSign(key)
        update(token)
        return sign()
    }
}

実行しても認証に成功しない。

署名の仕組み

ADB の署名はどのように行われているのか。

github.com

adb_auth_host.cpp に adb_auth_sign 関数がある。 下記を行うだけの簡単なお仕事。

RSA_sign(NID_sha1, token, token_size, sig, &len, key)

これは OpenSSL の RSA_sign 関数。 OpenSSL の RSA_sign 関数を調べてみる。 ソースコードを読むが知識なしには何を行っているのかわからない。 RSA_sign manpage も読んでみる。 PKCS #1 v2.0 というキーワードを得た。

RFC 2437 - PKCS #1: RSA Cryptography Specifications Version 2.0

知識不足の状態で英語を読むのは辛いと思っていたら、日本語で読み解いてくれている記事を発見。

qiita.com

記事を読み進めていくと、他記事へのリンク発見。 これこそが求めていた情報。 OpenSSL の RSA_sign 関数の中身はこれではなかろうか。

自堕落な技術者の日記 : 図説RSA署名の巻 - livedoor Blog(ブログ)

記事中の DigestInfo と RFC2437 の内容を較べてみる。 ダイジェストアルゴリズムは様々あるが RSA_sign 関数に NID_sha1 を渡していることから SHA-1 (Secure Hash Algorithm) と推測される。 RFC2437 中には他にも MD2, MD5 (Message Digest Algorithm) の記述がある。 RFC2437 から関係のありそうな部分のみを抜粋する。

DigestInfo ::= SEQUENCE {
     digestAlgorithm  AlgorithmIdentifier,
     digest OCTET STRING }

sha1Identifier ::= AlgorithmIdentifier { id-sha1, NULL }

id-sha1 OBJECT IDENTIFIER ::=
     { iso(1) identified-organization(3) oiw(14) secsig(3)
       algorithms(2) 26 }

上記の表記法は標準化されており ASN.1 (Abstract Syntax Notation One) と呼ばれている。 ASN.1 はデータ構造(型)を定義しており、実際のデータをこの型にあてはめていく。 データを当てはめるにあたっての規則として BER (Basic Encoding Rules) がある。

ASN.1 バイナリ変換規則 (BER, CER, DER)

自堕落な技術者の日記 : 図説RSA署名の巻 で図示されている DigestInfo の BER によるデータ表現は下記のとおり。

30:21:30:09:06:05:2b:0e:03:02:1a:05:00:04:14

バイトを読み解く。

  1. 0x30 : SEQUENCE のタグ番号は 0x10、構造型なので構造化フラグが 1、よって 0x30
  2. 0x21 : 33 bytes (13 bytes + トークンの 20 bytes)
  3. 0x30 : SEQUENCE
  4. 0x09 : 9 bytes
  5. 0x06 : OBJECT IDENTIFIER
  6. 0x05 : RFC2437 では iso(1) だが?
  7. 0x2b : RFC2437 では identified-organization(3) だが?
  8. 0x0e : oiw(14)
  9. 0x03 : secsig(3)
  10. 0x02 : algorighms(2)
  11. 0x1a : 26
  12. 0x05 : NULL
  13. 0x00 : 終端
  14. 0x04 : OCTET STRING
  15. 0x14 : 20 bytes
  16. 以降、トークンのデータ (20 bytes)

なお、サーバーから受信したトークンは 20 bytes のデータで下記のような内容であった。 SHA-1 は 20 bytes の値を生成するため、トークンは SHA-1ハッシュ値であると確信を深める。

07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

署名の内容を確認する

Signature.getInstance において NONEwithRSA を指定して署名を生成したとき、認証に失敗していた。 実際に生成されている署名を確認するために、実験用のコードを書く。 ECB については、理解してるつもりの SSL/TLS でも、もっと理解したら面白かった話 · けんごのお屋敷 の暗号モードが詳しい。

fun toByteArray(buffer: String): ByteArray =
        buffer.split(":").map { Integer.parseInt(it, 16).toByte() }.toByteArray()

fun ByteArray.toHexString(): String =
        joinToString(":") { String.format("%02x", it) }

fun testSign() {
    val keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair()
    val token = toByteArray("07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82")

    val signature = Signature.getInstance("NONEwithRSA").run {
        initSign(keyPair.private)
        update(token)
        sign()
    }
    val decryptedSignature = Cipher.getInstance("RSA/ECB/NoPadding").run {
        init(Cipher.DECRYPT_MODE, keyPair.public)
        doFinal(signature)
    }
    println("size: ${decryptedSignature.size}")
    println("data: ${decryptedSignature.toHexString()}")
}

実行結果。data は長いので 16 bytes ごとで改行している。

size: 256
data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

NONEwithRSA では、DigestInfo がなく、トークンのみ。

次に、NONEwithRSA を SHA1withRSA に変更して再度実行。

size: 256
data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:30:21:30:
09:06:05:2b:0e:03:02:1a:05:00:04:14:98:d2:0c:51:
8a:59:30:f8:64:07:5a:55:fd:68:a2:5f:d4:40:8e:b7

SHA1withRSA では、DigestInfo があり、トークンは SHA-1ハッシュ値になっている。

Signature では期待する結果を得られないので、Cipher を使用して署名を生成する。

fun testSign() {
    val keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair()
    val token = toByteArray("07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82")

    val signature = Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
        init(Cipher.ENCRYPT_MODE, keyPair.private);
        doFinal(token)
    }
    val decryptedSignature = Cipher.getInstance("RSA/ECB/NoPadding").run {
        init(Cipher.DECRYPT_MODE, keyPair.public)
        doFinal(signature)
    }
    println("size: ${decryptedSignature.size}")
    println("data: ${decryptedSignature.toHexString()}")
}

実行結果。

decr size: 256
decr data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

RSA/ECB/PKCS1Padding では、DigestInfo がない。

DIGEST_INFO を用意して追加。

    val DIGEST_INFO = arrayOf(
            0x30,0x21,0x30,0x09,0x06,0x05,0x2b,0x0e,0x03,0x02,0x1a,0x05,0x00,
            0x04,0x14).map { it.toByte() }.toByteArray()

    val signature = Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
        init(Cipher.ENCRYPT_MODE, keyPair.private);
        update(DIGEST_INFO) // 追加
        doFinal(token)
    }

実行結果。

decr size: 256
decr data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:30:21:30:
09:06:05:2b:0e:03:02:1a:05:00:04:14:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

望む結果が得られた。 得られた署名をサーバーに送信したところ、認証を通過できた。

公開鍵の形式

署名の問題は解決できたが、生成した公開鍵を送付すると署名の認証で失敗する。 配布されいる ADB コマンドが生成した公開鍵を送付して、秘密鍵で署名を行うと認証に成功する。 (macOS の環境では ~/.android ディレクトリの adbkey (秘密鍵), adbkey.pub (公開鍵)。) 送付している公開鍵に問題があるようだ。

再び、ソースコードを読んでみる。

platform_system_core/adb_auth_host.cpp at master · android/platform_system_core · GitHub

android_pubkey_encode 関数を呼んでいる。

platform_system_core/android_pubkey.c at master · android/platform_system_core · GitHub

公開鍵は下記の形式にエンコードしたうえで BASE64 エンコードされている。 ANDROID_PUBKEY_MODULUS_SIZE は (2048 / 8)。 鍵サイズは 2048 bits のようだ。

typedef struct RSAPublicKey {
    // Modulus length. This must be ANDROID_PUBKEY_MODULUS_SIZE.
    uint32_t modulus_size_words;

    // Precomputed montgomery parameter: -1 / n[0] mod 2^32
    uint32_t n0inv;

    // RSA modulus as a little-endian array.
    uint8_t modulus[ANDROID_PUBKEY_MODULUS_SIZE];

    // Montgomery parameter R^2 as a little-endian array of little-endian words.
    uint8_t rr[ANDROID_PUBKEY_MODULUS_SIZE];

    // RSA modulus: 3 or 65537
    uint32_t exponent;
}

android_pubkey_encode 関数を参考にして、Kotlin で書いてみる。

    fun adbEncode(key: PublicKey): ByteArray {
        if (key !is RSAPublicKey) throw IllegalArgumentException("PublicKey is not RSAPublicKey.")

        val modulesSize = 2048 / 8
        val moduleSizeWords = modulesSize / 4

        // 2^32
        val r32 = BigInteger.ZERO.setBit(32)

        // -1 / N[0] mod 2^32
        val n0inv = r32.subtract(
                key.modulus.remainder(r32).modInverse(r32)).toInt()

        val modules = key.modulus
                .toByteArray().reversedArray()

        // (2^(rsa_size)) ^ 2 mod N
        val rr = BigInteger.ZERO.setBit(modulesSize * 8)
                .modPow(BigInteger.valueOf(2), key.modulus)
                .toByteArray().reversedArray()

        val exponent = key.publicExponent.toInt()

        val encodedPubKey = ByteArray(4 * 3 + modulesSize * 2).apply {
            ByteBuffer.wrap(this).run {
                order(ByteOrder.LITTLE_ENDIAN)
                putInt(moduleSizeWords)
                putInt(n0inv)
                if (modules.size < modulesSize) {
                    put(modules)
                    put(ByteArray(modulesSize - modules.size)) // 桁が不足する場合は 0 で埋める
                } else {
                    put(modules, 0, modulesSize) // 符号の桁は除く
                }
                if (rr.size < modulesSize) {
                    put(rr)
                    put(ByteArray(modulesSize - rr.size)) // 桁が不足する場合は 0 で埋める
                } else {
                    put(rr, 0, modulesSize) // 符号の桁は除く
                }
                putInt(exponent)
            }
        }

        return encodedPubKey
    }

adbEncode 関数の戻り値を BASE64 エンコードしてサーバーに送信することで、署名による認証に成功するようになった。

以上で、ユーザーにダイアログを表示することなく、自スマホに ADB 接続できるようになった。

まとめ

  • ADB は USB だけでなく、TCP でも接続できる
  • アプリからコマンドを実行するよりも ADB でコマンドを実行する方が多くの権限を与えられる
  • TCP 接続を使えば、アプリから ADB の権限でコマンドを実行できる
  • 但し、USB 接続で adb tcpip コマンドを実行しておく必要がある
  • ADB Protocol Documentation に従えば、実装できる
    • 但し、CRC32 は使用しておらず、単純なチェックサム
    • データが文字列の場合には最後のバイトを 0 にする
    • 送信する署名は、パディング、DigestInfo、トークンをまとめて秘密鍵で暗号化 (RSA/ECB/PKCS1Padding を指定して、DigestInfo の ERB エンコードしたデータを加える)
    • 送信する公開鍵は、struct RSAPublicKey のバイト列を BASE64 エンコードしたデータ (Little Endian)

たくさんの有益な情報を提供してくださっている、各ブログとプロジェクトに感謝!

ADB (Android Debug Bridge) のプロトコル 前編

概要

ADB の Java クライアントを試作してみようと取り組んでみたら深みにはまっていったお話。 とても長いので前後編に分けました。

雑談

USB ケーブルがなくっても使える ADB

ADB (Android Debug Bridge) は Android アプリ開発者にとってはおなじみのツール。 普段、ADB を使用するときは USB で PC とスマホを接続する。 ADB は USB だけでなく TCP で接続することもできる。 TCP で接続すれば、PC からスマホだけでなく、スマホからスマホにネットワーク経由で接続することができる。 さらに言えば、スマホからスマホ自身に接続することができる。 そんなことをして何が嬉しいのかって?

ADB を使えばできることの幅が拡がる

eligor13.hatenablog.jp

こちらは、スマホからスマホ自身に接続している例。 パッケージの無効化をやっている。 プリセットアプリが邪魔だと思ったことはありませんか。

他にも色々とできます。 例えば、input コマンド。

先ほどの例では、スマホに adb コマンドがインストールされていることが前提となっている。 しかし、Xperia Z5 には adb コマンドはインストールされていない。 そんなときは Terminal Emulator。 Terminal Emulator を使えばコマンドを使える。

play.google.com

input コマンドを Terminal Emulator で使ってみる。

$ input tap 100 300

input コマンドはタップ操作をエミュレートすることができる。 上記の例では、座標 (100, 300) の位置がタップされる。 左上が (0, 0) なので、Terminal Emulator のコマンド入出力画面がタップされる。 うまく使えば自動操作くらいはできる。

ところが、どの座標でもタップされるわけではない。 ソフトウェアキーボードの領域を input コマンドでタップさせてみてもコマンドは機能せず、Terminal Emulator の領域のみ機能する。 コマンドを実行する際には権限が影響してくる。 Terminal Emulator では一般ユーザー権限としてコマンドを実行する。 一般ユーザー権限では使える機能が制限される。

そこで登場するのが ADB。

スマホには ADB がインストールされていないので、PC から接続してみる。 Android アプリ開発者にはおなじみの方法。 スマホChrome を立ち上げた状態で、下記のコマンドを PC で実行してみる。

$ adb shell input tap 100 100

Chrome の画面左上のホームボタンがタップされた。 ADB Shell を使った場合には、ソフトウェアキーボードの領域であろうがタップできる。 ADB Shell を使った場合の権限は一般ユーザー権限よりも多くの権限を与えられている。 root 権限であれば全てを操作できるが、全ての操作は行えていないので root 権限ではない。

ADB を使えば、アプリからは使えない機能だって使える。

Terminal Emulator の裏側

Terminal Emulator のソースコードを読んだわけではないが、想像するに次のようなことを行っていると思われる。

Runtime.getRuntime().exec("input 1000 100")

Runtime (ProcessBuilder の方がよいかも) を使えば、コマンドを実行できる。

しかし、上記の処理を実行して自アプリ以外の領域 (例えば、ソフトウェアキーボード) の領域をタップすると、 android.permission.INJECT_EVENTS 権限のエラーが表示される。 AndroidManifest.xml に INJECT_EVENTS 権限を加えてみても解決はしない。 権限についてはセキュリティのための仕掛けがあり、普通のアプリでは自アプリ以外の領域に input コマンドによるタップイベントは送れない。

blog.onpu-tamago.net

input コマンドを使って発生させるタップイベントは

  • 自アプリからは自アプリの領域のみに送れる
  • ADB からはどこの領域でも送れる

スマホだって ADB を使いたい

では、ADB を自アプリから接続したらどうだろう。 はじめに話したとおり、ADB は TCP 接続も可能になっている。 但し、一度は USB で接続する必要がある。 USB で接続して次のコマンドを実行する。

$ adb tcpip 5555

5555 はポート番号。スマホ側はこのポート番号で ADB の通信を待ち受けるようになる。 上記だけでスマホ側 (サーバー) の準備は完了。

次は、サーバーに接続するクライアント。 自作するのは面倒なので Java のライブラリを探すと JADB なるライブラリがみつかる。

github.com

いざ使ってみると動かない。おや?使い方が悪いのか? ソースを読むと送信しているデータが随分とシンプルに思える。

ADB のプロトコル文書を探すと、こちらも見つかる。ラッキー。

github.com

そんなに複雑ではなさそうなので、これなら自作できるか。

いざ作り始めると、はまる。 まず、CRC32 と書いてあるから CRC32 を理解するために調べてみた。 CRC32 はデータの破損を検出するための技術。

CRC32 を生成する処理を実装してみた。 配布されている ADB でコマンドを実行し、ADB とスマホの通信を WireShark でキャプチャーする。 キャプチャーしたパケットのデータと CRC32 を取得。 実装した処理でデータの CRC32 を生成してみるが、キャプチャーした CRC32 と一致しない。 何が違う?

ADB Test - Google グループ

文書に書かれている CRC32 は嘘だった。 ただのチェックサムだ。 データの各バイトを加算するだけという簡単な仕様。 いずれは CRC32 にするつもりなのか。 実装を CRC32 からチェックサムに置き換えたところ、キャプチャーしたパケットと一致。 無事に解決。

次のハマりポイントは認証。仕組みは次のとおり。

  1. サーバーはクライアントにトークンを送信
  2. クライアントは RSA秘密鍵トークンを使って署名を生成
  3. クライアントはサーバーに署名を送信
  4. サーバーは署名を検証
  5. 検証した結果、既知のクライアントであると識別できたら接続を確立

サーバーが署名を検証するためには、サーバーに公開鍵をあらかじめ渡す必要がある。 処理が加わる。

  1. サーバーは署名を検証できなかったときに、再びトークンを送信
  2. クライアントは RSA の公開鍵をサーバーに送信
  3. サーバーはユーザーに確認を求める
  4. ユーザーが許可すると接続を確立

送信する公開鍵の後ろには user@host を付与する。 4096 ビットで生成した鍵を送付したところ受け付けられた。 ハマりポイントは最後に 0 (文字ではなく、数値として) を付与する必要がある。 後から思えば不思議ではないが、トークンや署名はバイト列だから最後に 0 は不要、公開鍵は文字列なので 0 が必要という仕様。 C 言語で書くことを思えば納得。

公開鍵のデータは、~/.android/adbkey.pub (macOS 環境の場合) ファイルの内容を送ればよいということだ。

Socket のコンストラクタでは、スマホの IP とポート番号 (adb tcpip コマンドで指定した番号) を指定。 プロトコル文書に従って構築したパケットを送信することで、 クライアントとサーバーの接続が完了して、コマンドを実行する準備が整った。

後編に続く。

画面のスナップショットを撮影する

概要

Android で画面のスナップショットを撮影するアプリを作ります。 他のアプリが起動しているときにスナップショットを撮影するために、サービス経由でスナップショット撮影を行います。

確認環境

参考情報

解説

MediaProjection を使用することで画面のスナップショットを撮影できる。 他アプリを実行中やホーム画面など、様々な画面のスナップショットを撮影するためには Service で実行する必要がある。 MediaProjection を使用するためには startActivityForResult を呼び出す必要があるが、Service では呼び出せない。 そのため、MediaProjection を取得する Activity (サンプルコードでは CaptureActivity) を用意し、Activity を Service から起動する。 この Activity は表示されないようにし、MediaProjection 取得後に終了する。 取得した MediaProjection は、Service から参照できるように static フィールド (Kotlin では companion object) に保持する。 Service では、Activity が保持している MediaProjection を参照して利用する。

サンプルコードを動かすにあたって、スナップショット撮影を Service から起動するためのボタンが欲しい。 下記の内容に従ってボタンを作成する。

nosix.hatenablog.com

ボタンをタップしたら、サンプルコードの CaptureService::enableCapture メソッドを呼ぶようにする。

フローティングアプリを作るためのはじめの一歩

概要

Android で他アプリを実行中でも前面に表示されるアプリ(フローティングアプリ、Xperia のスモールアプリのようなもの)を作るため、 他アプリを実行中にビューを表示できることを確認します。 さらに、フローティングアプリの移動とタップが可能であること、他アプリの操作が行えることを確認します。

確認環境

参考情報

解説

Android の画面表示には層 (Layer) がある。 blog.nagopy.com: Androidユーザー向け 「何となくわかる表示レイヤー講座 ~オーバーレイ表示の仕組み~」 を参考に前面の層から順に並べると下記のようになる。 TYPE_* は後述する WIndowManager.LayoutParams (以後 LayoutParams と呼ぶ) のコンストラクタで指定する定数である。

  1. システムオーバーレイ (TYPE_SYSTEM_OVERLAY)
  2. ステータスバー
  3. キーガード(ロック画面)
  4. システムアラート (TYPE_SYSTEM_ALERT)
  5. トースト (TYPE_TOAST)
  6. 着信画面 (TYPE_PHONE)
  7. 通常のアプリ (TYPE_APPLICATION)

通常のアプリよりも前面の層にビューを表示すれば、他アプリを実行中でも表示されるようになる。 前面の層にビューを表示するためには WindowManager の addView メソッドを使う。 addView メソッドには、表示する View オブジェクトと LayoutParams オブジェクトを渡す。 LayoutParams で TYPE_* を指定することで表示する層を指定できる。 TYPE_* は上記以外にも存在する (WindowManager.LayoutParams | Android Developers) が、 全ての TYPE_* を指定できるわけではない。

上位の層にビューを配置すれば如何なる場合にも表示されるようになるが、 TYPE_SYSTEM_OVERLAY ではタッチイベントを捕捉できない。 TYPE_SYSTEM_ALERT であればタッチイベントを捕捉でき、トーストや着信よりも前面に表示される。 トーストの表示を邪魔したくなければ TYPE_PHONE にすればよい。 LayoutParams で MATCH_PARENT を指定するとタッチイベントが通常のアプリに渡らなくなるので WRAP_CONTENT にする。

フローティングアプリの表示位置は LayoutParams で管理する。 LayoutParams の x, y プロパティを設定した上で、WindowManager の updateViewLayout を呼ぶことで表示位置が変更される。

フローティングアプリは、他アプリが実行中に表示される必要があるのでアクティビティではなくサービスとして実装する。 サービスが他アプリの影響で出来る限り終了しないようにしたければ、startForeground と Service.START_STICKY を忘れずに。 サービスが自動的に再起動する際には、intent が null で onStartCommand が呼ばれるので注意が必要である。 詳しくは 落ちないサービスでアプリの起動を監視する - NOSIX 参照。

Android 6.0 からはセキュリティが厳しくなり、ユーザーが他のアプリの上に表示を許可しなければならない。 Settings.canDrawOverlays メソッドにより許可が与えられていることを確認し、許可されていなければ startActivityForResult で許可設定の画面を表示する。 従来どおり android.permission.SYSTEM_ALERT_WINDOW の権限も必要である。

Kotlin 解説

Kotlin に詳しくない方のために、少しだけ Kotlin の補足。

拡張関数と拡張プロパティ

Extensions.kt で使われている

fun クラス名.関数名()

が拡張関数の定義です。 fun Activity.hasOverlayPermission() とすることで Activity クラスに hasOverlayPermission メソッドがあるかのように Activity クラスを使えます。 MainActivity は Activity クラスを継承しており、MainActivity クラスは hasOverlayPermission メソッドを持っているかのように振る舞います。

FloatingButton.kt では

val クラス名.プロパティ名
var クラス名.プロパティ名

として拡張プロパティを定義しています。 拡張関数と同様に振る舞います。 var WindowManager.LayoutParams.position とすることで WindowManager.LayoutParams クラスが position プロパティを持つように振る舞います。

initial = params.position - e.position の params は WindowManager.LayoutParams のインスタンスです。 params.position として参照することで拡張プロパティで定義した position プロパティの get() が呼び出され、 Position(x.toFloat(), y.toFloat()) が実行されます。 x, y は WindowManager.LayoutParams のプロパティです。

params.position = it + e.position としてプロパティに代入した場合には、position プロパティの set(value) が呼び出されます。

プロパティは Java で言うところのフィールド、セッター、ゲッターをひとつにまとめたものです。 今回の拡張プロパティではセッターとゲッターのみを定義し、フィールドは定義していません。 フィールドを定義する場合には、set(value)field 変数を使います。 FloatingButton.kt の visible プロパティではフィールドを定義しています。

run 関数

下記のコードは同じことを行っています。

// Kotlin
button?.run {
    visible = false
}
// Java
if (button != null) {
    button.setVisible(false);
}

?. は null ではないときのみメソッドを実行します。

run 関数はスコープ関数と呼ばれる関数の一種で、拡張関数として定義されています。 Kotlin ではメソッドではなく関数 (function) と呼ぶようです。 Java のメソッドは Kotlin ではメンバー関数 (member function) と呼ぶことがありますが、 本記事ではクラスに属している関数 (member function) をメソッドと呼んでいます。

apply 関数

下記のコードは同じことを行っています。

// Kotlin
button = FloatingButton(windowManager, this).apply {
    visible = true
}
// Java
button = new FloatingButton(windowManager, this);
button.setVisible(true);

apply 関数も run 関数と同様にスコープ関数と呼ばれる関数の一種で、拡張関数として定義されています。

run 関数との異なる点は関数の戻り値です。 run 関数の戻り値はブロック内で最後に評価された値です。 先の例では、visible = false の結果が Unit 型 (Java で言うところの Void のような型) なので、run 関数は Unit オブジェクトを返します。 apply 関数の戻り値はレシーバーオブジェクトです。 例では、FloatingButton オブジェクトに対して apply 関数を呼び出しているため、レシーバーオブジェクトは FloatingButton オブジェクトです。 つまり、apply 関数の戻り値は FloatingButton オブジェクトになります。