Proxyで傍受可能なオレオレ証明書を通せるAndroidアプリの作り方

人間誰しも1度はProxyを通して通信内容をデバッグしたい時がある。
要はオレオレ証明書を発行して間に挟めば傍受も改ざんもやりたい放題なのだが、Android 7以降からは一筋縄では実現できない。

In Android Nougat, we’ve changed how Android handles trusted certificate authorities (CAs) to provide safer defaults for secure app traffic. Most apps and users should not be affected by these changes or need to take any action. The changes include:

  • Safe and easy APIs to trust custom CAs.
  • Apps that target API Level 24 and above no longer trust user or admin-added CAs for secure connections, by default.
  • All devices running Android Nougat offer the same standardized set of system CAs—no device-specific customizations.

https://android-developers.googleblog.com/2016/07/changes-to-trusted-certificate.html

上記のとおり、AOSPで設定されたCA以外は信頼されず、ユーザが独自にインストールしたCAすらもデフォルトで無効になる。
しかしこれらをオプトインで利用することも当然可能になっており、またアプリ内にderファイルを同梱させ利用させることも可能だ。

これらは設定ファイルを記述して自分のアプリに組み込めば実現できる。

今回目指すこと

今回は、

  • Proxyサーバが、アプリが要求した https://example.com の内容を傍受できるようにする
  • 上記の内容を改竄し、アプリがへ届けられるようにする

の2つの実現を目指す。
ただし、傍受や改竄自体は便利なアプリが複数存在する(たとえば Burp Suite など)ので、実際に行うのは独自CAの許可のみだ。

以降で実装手順を解説する。実際のコードはGitHubに掲載した。

github.com

1. Response bodyを表示する簡単なアプリを作る

まずは挙動が確認できないことには始まらないので、通信した結果を表示するだけの画面を作る。
ざっくり以下のようになるはずだ:

class MainActivity : AppCompatActivity() {

    companion object {

        private const val ENDPOINT = "https://example.com"

    }

    private val client = OkHttpClient()

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

        findViewById<Button>(R.id.button)
            .setOnClickListener {
                val req = Request.Builder()
                    .url(ENDPOINT)
                    .build()

                GlobalScope.launch(Dispatchers.IO) {
                    val result = client.newCall(req).execute().let {
                        it.body()?.string() ?: it.code().toString()
                    }

                    GlobalScope.launch(Dispatchers.Main) {
                        findViewById<TextView>(R.id.text)
                            .text = result
                    }
                }
            }
    }
}

このような表示を期待する。

f:id:S64:20190424173808p:plain:w250

2. 構成ファイルを作成・配置する

今回の設定はアプリ内でのみ有効になる。試しに

  • 全ての通信において、プリインストールのCAとres/raw/myca.derを信頼する

という設定にし、src/main/res/xml/nsc.xml に配置した:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="@raw/myca" />
        </trust-anchors>
    </base-config>
</network-security-config>

3. CAを追加する

前項で設定したとおり、src/main/res/raw/myca.derとして追加する。

4. Manifestに設定する

nsc.xmlというファイル名自体には意味がなく、これを AndroidManifest.xml から参照する必要がある。

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    ...

    <application
            ...
            android:networkSecurityConfig="@xml/nsc">
        ...
    </application>

</manifest>

<application/> 要素に android:networkSecurityConfig で設定する。

以上で、独自証明書を用いた通信の傍受や改竄が可能になったはずだ。

`Cannot find a version of '*' that satisfies the version constraints` というエラーがでた時

Android開発で、Gradleから以下のようなエラーが出た:

> Task :app:lint FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:lint'.
> Could not resolve all artifacts for configuration ':${submodule}:debugAndroidTestRuntimeClasspath'.
   > Could not resolve com.android.support:support-annotations:28.0.0.
     Required by:
         project :${submodule}
      > Cannot find a version of 'com.android.support:support-annotations' that satisfies the version constraints: 
           Dependency path '${project}:${submodule}:unspecified' --> 'com.android.support:support-annotations:28.0.0'
           Constraint path '${project}:${submodule}:unspecified' --> 'com.android.support:support-annotations' strictly '26.1.0' because of the following reason: debugRuntimeClasspath uses version 26.1.0

これはなにかというと、マルチモジュールなプロジェクトで依存関係を組んでいる時、

  • 上位モジュールが要求するライブラリがstrictにバージョンを指定している
  • その下位のモジュールは当該ライブラリを直接利用はしていない
  • が、このモジュールの依存する他のライブラリが要求するため、間接的に依存している
    • そしてこの間接的に使われる当該ライブラリバージョンは上位のモノと一致していない
  • 結果、ライブラリバージョンがconflictしているために決定できない

という状況で発生する。


すなわちやることはシンプルで、下位モジュールにも同様に設定すればいい。
今回の場合は、上位のモジュールで28.0.0が打たれているcom.android.support:support-annotations が、下位のモジュールではバージョンがunspecifiedになっているわけなので、その依存を追加すればいい:

...
dependencies {
    implementation 'com.android.support:support-annotations:28.0.0'
}
...

Android Lintで`Unknown issue id "all"`が出る場合

Android Lintを掛けようとして、こんなエラーが出た:

Error: Unknown issue id "all", found in /${projectdir}/lint.xml [LintError]

設定ファイルの当該箇所はこれ:

<?xml version="1.0" encoding="UTF-8"?>
<lint>
    ...
    <issue id="all"> <!-- 🈁 -->
        <ignore path="..."/>
        ...
    </issue>
    ...
</lint>

使っていたAndroid Gradle Pluginはこれ:

...
buildscript {
    ...
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        ...
    }
}
...

これはなにかというと、単にAndroid Gradle Pluginのバグである。 ここで報告されていた
この問題はAndroid Gradle Plugin 3.3.0で既に修正されている。

...
buildscript {
    ...
    dependencies {
        classpath 'com.android.tools.build:gradle:3.3.0'
        ...
    }
}
...

ただし、このAndroid Gradle Plugin 3.3.0は要求するGradleバージョンの引き上げも含まれるため注意。

> Failed to apply plugin [id 'com.android.application']
   > Minimum supported Gradle version is 4.10.1. Current version is [currentversion]. If using the gradle wrapper, try editing the distributionUrl in /${projectdir}/gradle/wrapper/gradle-wrapper.properties to gradle-4.10.1-all.zip

具体的には、gradle-4.10.1以上にしなければいけない。

./gradlew wrapper --gradle-version=4.10.1
# 
# Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
# See https://docs.gradle.org/4.8.1/userguide/command_line_interface.html#sec:command_line_warnings
# 
# BUILD SUCCESSFUL in 5s
# 1 actionable task: 1 executed
git status
# On branch ${yourbranch}
# Your branch is ahead of 'origin/${yourbranch}' by ${num} commit.
#   (use "git push" to publish your local commits)
# 
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
# 
#  modified:   gradle/wrapper/gradle-wrapper.properties
# 
# no changes added to commit (use "git add" and/or "git commit -a")
./gradlew wrapper
# 
# Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
# Use '--warning-mode all' to show the individual deprecation warnings.
# See https://docs.gradle.org/4.10.1/userguide/command_line_interface.html#sec:command_line_warnings
# 
# BUILD SUCCESSFUL in 5s
# 1 actionable task: 1 executed
git status
# On branch ${yourbranch}
# Your branch is ahead of 'origin/${yourbranch}' by ${num} commit.
#   (use "git push" to publish your local commits)
# 
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
# 
#  modified:   gradle/wrapper/gradle-wrapper.jar
#  modified:   gradle/wrapper/gradle-wrapper.properties
# 
# no changes added to commit (use "git add" and/or "git commit -a")

Gitリポジトリの中身を全部一気に消す

Gitリポジトリの中のファイルを一括で消すコマンドといえば、よく言われるのはこれ:

git rm -rf .

しかしこれはちょっと問題があって、

  • GitでUntrackedなファイルは削除対象じゃない
  • 削除対象ファイルが0コの場合はエラーになる

というちょっと扱いづらいもの。


ならば当然こういう選択肢が出る:

rm -rf ./*

ただこれだと今度は.gitディレクトリまで削除されてしまう。


そこでオススメするonelinearはコチラ:

git clean -fdx && test $(git ls-files | wc -l) -eq 0 || git rm -rf .

ちょっと解説。

git clean -fdx

git clean -fdxのオプションは、

  • -f: force
  • -d: ディレクトリを対象に
  • -x: ignore対象も削除

これでまずUntrackedなファイル含めクリーンに。

&& test $(git ls-files | wc -l) -eq 0

次に test $(git ls-files | wc -l) -eq 0 では、

  • gitの管理するファイル一覧の行数をカウントし、
  • ファイル数が0ならOK、
  • そうでない場合はエラーにする

という内容。

|| git rm -rf .

最後に注目すべきは、||でつなげてあるということ。
これにより、手前のtestが失敗している(= ファイルが残っている)なら実行するし、成功している(= ファイルがもうない)なら実行されない。

よって、エラーを吐かせずにリポジトリの中身がクリーンにできちゃう。

sonarqube-gradle-pluginで `Component key * not found` となった時

org.sonarsource.scanner.gradle:sonarqube-gradle-plugin を使っていて、設定次第ではまれにこのようなエラーが出る場合がある:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':sonarqube'.
> Unable to load component class org.sonar.scanner.report.MetadataPublisher

* Try:
Run with --debug option to get more log output. Run with --scan to get full insights.

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':sonarqube'.
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeActions(ExecuteActionsTaskExecuter.java:103)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:73)
    at org.gradle.api.internal.tasks.execution.OutputDirectoryCreatingTaskExecuter.execute(OutputDirectoryCreatingTaskExecuter.java:51)
    at org.gradle.api.internal.tasks.execution.SkipUpToDateTaskExecuter.execute(SkipUpToDateTaskExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.ResolveTaskOutputCachingStateExecuter.execute(ResolveTaskOutputCachingStateExecuter.java:54)
    at org.gradle.api.internal.tasks.execution.ValidatingTaskExecuter.execute(ValidatingTaskExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.SkipEmptySourceFilesTaskExecuter.execute(SkipEmptySourceFilesTaskExecuter.java:101)
    at org.gradle.api.internal.tasks.execution.FinalizeInputFilePropertiesTaskExecuter.execute(FinalizeInputFilePropertiesTaskExecuter.java:44)
    at org.gradle.api.internal.tasks.execution.CleanupStaleOutputsExecuter.execute(CleanupStaleOutputsExecuter.java:91)
    at org.gradle.api.internal.tasks.execution.ResolveTaskArtifactStateTaskExecuter.execute(ResolveTaskArtifactStateTaskExecuter.java:62)
    at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:54)
    at org.gradle.api.internal.tasks.execution.ExecuteAtMostOnceTaskExecuter.execute(ExecuteAtMostOnceTaskExecuter.java:43)
    at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:34)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker$1.run(DefaultTaskGraphExecuter.java:256)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:336)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:328)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.execute(DefaultBuildOperationExecutor.java:199)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.run(DefaultBuildOperationExecutor.java:110)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker.execute(DefaultTaskGraphExecuter.java:249)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker.execute(DefaultTaskGraphExecuter.java:238)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.processTask(DefaultTaskPlanExecutor.java:123)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.access$200(DefaultTaskPlanExecutor.java:79)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker$1.execute(DefaultTaskPlanExecutor.java:104)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker$1.execute(DefaultTaskPlanExecutor.java:98)
    at org.gradle.execution.taskgraph.DefaultTaskExecutionPlan.execute(DefaultTaskExecutionPlan.java:663)
    at org.gradle.execution.taskgraph.DefaultTaskExecutionPlan.executeWithTask(DefaultTaskExecutionPlan.java:597)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.run(DefaultTaskPlanExecutor.java:98)
    at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:63)
    at org.gradle.internal.concurrent.ManagedExecutorImpl$1.run(ManagedExecutorImpl.java:46)
    at org.gradle.internal.concurrent.ThreadFactoryImpl$ManagedThreadRunnable.run(ThreadFactoryImpl.java:55)
Caused by: java.lang.IllegalStateException: Unable to load component class org.sonar.scanner.report.MetadataPublisher
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:65)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:621)
    at org.picocontainer.parameters.CollectionComponentParameter.getArrayInstance(CollectionComponentParameter.java:334)
    at org.picocontainer.parameters.CollectionComponentParameter.access$100(CollectionComponentParameter.java:49)
    at org.picocontainer.parameters.CollectionComponentParameter$1.resolveInstance(CollectionComponentParameter.java:139)
    at org.picocontainer.parameters.ComponentParameter$1.resolveInstance(ComponentParameter.java:141)
    at org.picocontainer.injectors.SingleMemberInjector.getParameter(SingleMemberInjector.java:78)
    at org.picocontainer.injectors.ConstructorInjector$CtorAndAdapters.getParameterArguments(ConstructorInjector.java:309)
    at org.picocontainer.injectors.ConstructorInjector$1.run(ConstructorInjector.java:335)
    at org.picocontainer.injectors.AbstractInjector$ThreadLocalCyclicDependencyGuard.observe(AbstractInjector.java:270)
    at org.picocontainer.injectors.ConstructorInjector.getComponentInstance(ConstructorInjector.java:364)
    at org.picocontainer.injectors.AbstractInjectionFactory$LifecycleAdapter.getComponentInstance(AbstractInjectionFactory.java:56)
    at org.picocontainer.behaviors.AbstractBehavior.getComponentInstance(AbstractBehavior.java:64)
    at org.picocontainer.behaviors.Stored.getComponentInstance(Stored.java:91)
    at org.picocontainer.DefaultPicoContainer.instantiateComponentAsIsStartable(DefaultPicoContainer.java:1034)
    at org.picocontainer.DefaultPicoContainer.addAdapterIfStartable(DefaultPicoContainer.java:1026)
    at org.picocontainer.DefaultPicoContainer.startAdapters(DefaultPicoContainer.java:1003)
    at org.picocontainer.DefaultPicoContainer.start(DefaultPicoContainer.java:767)
    at org.sonar.core.platform.ComponentContainer.startComponents(ComponentContainer.java:135)
    at org.sonar.core.platform.ComponentContainer.execute(ComponentContainer.java:122)
    at org.sonar.scanner.task.ScanTask.execute(ScanTask.java:48)
    at org.sonar.scanner.task.TaskContainer.doAfterStart(TaskContainer.java:82)
    at org.sonar.core.platform.ComponentContainer.startComponents(ComponentContainer.java:136)
    at org.sonar.core.platform.ComponentContainer.execute(ComponentContainer.java:122)
    at org.sonar.scanner.bootstrap.GlobalContainer.executeTask(GlobalContainer.java:131)
    at org.sonar.batch.bootstrapper.Batch.doExecuteTask(Batch.java:116)
    at org.sonar.batch.bootstrapper.Batch.executeTask(Batch.java:111)
    at org.sonarsource.scanner.api.internal.batch.BatchIsolatedLauncher.execute(BatchIsolatedLauncher.java:63)
    at org.sonarsource.scanner.api.internal.IsolatedLauncherProxy.invoke(IsolatedLauncherProxy.java:60)
    at com.sun.proxy.$Proxy96.execute(Unknown Source)
    at org.sonarsource.scanner.api.EmbeddedScanner.doExecute(EmbeddedScanner.java:233)
    at org.sonarsource.scanner.api.EmbeddedScanner.runAnalysis(EmbeddedScanner.java:151)
    at org.sonarqube.gradle.SonarQubeTask.run(SonarQubeTask.java:99)
    at org.gradle.internal.reflect.JavaMethod.invoke(JavaMethod.java:73)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.doExecute(StandardTaskAction.java:46)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:39)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:26)
    at org.gradle.api.internal.AbstractTask$TaskActionWrapper.execute(AbstractTask.java:788)
    at org.gradle.api.internal.AbstractTask$TaskActionWrapper.execute(AbstractTask.java:755)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter$1.run(ExecuteActionsTaskExecuter.java:124)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:336)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:328)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.execute(DefaultBuildOperationExecutor.java:199)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.run(DefaultBuildOperationExecutor.java:110)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeAction(ExecuteActionsTaskExecuter.java:113)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeActions(ExecuteActionsTaskExecuter.java:95)
    ... 30 more
Caused by: java.lang.IllegalStateException: Unable to load component class org.sonar.scanner.rule.ModuleQProfiles
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:65)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:632)
    at org.picocontainer.parameters.BasicComponentParameter$1.resolveInstance(BasicComponentParameter.java:118)
    at org.picocontainer.parameters.ComponentParameter$1.resolveInstance(ComponentParameter.java:136)
    at org.picocontainer.injectors.SingleMemberInjector.getParameter(SingleMemberInjector.java:78)
    at org.picocontainer.injectors.ConstructorInjector$CtorAndAdapters.getParameterArguments(ConstructorInjector.java:309)
    at org.picocontainer.injectors.ConstructorInjector$1.run(ConstructorInjector.java:335)
    at org.picocontainer.injectors.AbstractInjector$ThreadLocalCyclicDependencyGuard.observe(AbstractInjector.java:270)
    at org.picocontainer.injectors.ConstructorInjector.getComponentInstance(ConstructorInjector.java:364)
    at org.picocontainer.injectors.AbstractInjectionFactory$LifecycleAdapter.getComponentInstance(AbstractInjectionFactory.java:56)
    at org.picocontainer.behaviors.AbstractBehavior.getComponentInstance(AbstractBehavior.java:64)
    at org.picocontainer.behaviors.Stored.getComponentInstance(Stored.java:91)
    at org.picocontainer.DefaultPicoContainer.getInstance(DefaultPicoContainer.java:699)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:647)
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:63)
    ... 75 more
Caused by: Failed to load the quality profiles of project 'project': Component key 'project' not found

最後のここに注目:

Caused by: Failed to load the quality profiles of project 'project': Component key 'project' not found

この Component key '*' not found の部分がプロジェクトのルートディレクトリ名だった場合、恐らく問題は簡単。

ドキュメントによると、このプラグインはprojectKey設定の際、デフォルトではルートのプロジェクト名を用いる。
そしてGradleはルートのプロジェクトにプロジェクト名が設定されていない場合、そのプロジェクトのルートディレクトリ名を用いる。

開発現場ではルートディレクトリ名はGitリポジトリ名とリンクしていることが多いため、開発者ほぼ全員が偶然期待通りの設定がデフォルト値として用いられる。
しかし例えばCircleCIなどのSaaSでは、プロジェクトがcloneされる先が ~/project となるため、この前提が崩れるのである。

解決方法

このように環境によって不安定な状態で用いるのは望ましくないが、手っ取り早く治すなら プロジェクトルートにある settings.gradle へ以下の1行を書き足せば良い。

rootProject.name = '期待するディレクトリ名'

余談

私はこれで2営業日とかしました

Makefile上でGitHubのPull Request URLからPull Request Numberを取り出す方法

たとえばCIRCLE_PULL_REQUESTという環境変数にフルのGitHub Pull Requestの URLが入っているとする。具体的にはこんな感じ:

export CIRCLE_PULL_REQUEST=https://github.com/octocat/Spoon-Knife/issues/1
echo $CIRCLE_PULL_REQUEST
# https://github.com/octocat/Spoon-Knife/issues/1

この変数からpullreq-numberを取り出すのは、通常のシェルではカンタンに実現できる:

echo ${CIRCLE_PULL_REQUEST##*/}
# 1

しかしMakefile上では事前に変数が展開されてしまうため、以下のようなファイルを用意し:

SHELL:=/bin/sh

test:
        echo blam! && \
                echo ${CIRCLE_PULL_REQUEST##*/} && \
                echo 'done.'

実行すると:

make test
# echo blam! && \
#      echo  && \
#      echo 'done.'
# blam!
# 
# done.

うまくいかないのである。


すなわちescapeすればよい:

SHELL:=/bin/sh

test:
        echo blam! && \
                echo $${CIRCLE_PULL_REQUEST##*/} && \
                echo 'done.'

diff

5c5
<       echo ${CIRCLE_PULL_REQUEST##*/} && \
---
>        echo $${CIRCLE_PULL_REQUEST##*/} && \

これでうまくいく。

make test
# echo blam! && \
#      echo ${CIRCLE_PULL_REQUEST##*/} && \
#      echo 'done.'
# blam!
# 1
# done.

変数に収めたい時はこうするとよい:

SHELL:=/bin/sh
CIRCLE_PR_NUMBER?=$(shell echo $${CIRCLE_PULL_REQUEST\#\#*/})

test:
        echo blam! && \
                echo ${CIRCLE_PR_NUMBER} && \
                echo 'done.'
make test
# echo blam! && \
#      echo 1 && \
#      echo 'done.'
# blam!
# 1
# done.

Mockitoの`verify`カウント数をリセットする方法

Mockito はとても便利で、たとえばこんな風にするだけで「このメソッドが叩かれたか?」というチェックができる:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent(); // 実際の処理を無視しておく

obj.doSomething(); // 内部で1回だけ`MyObj#firedMyEvent`が叩かれる
verify(obj, times(1)).firedMyEvent(); // ここでassert

よくある利用ケースとして、何回か同じイベントが叩かれることをチェックしたい時がある:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent();
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent();
}
// ここで`reset(obj)`すればいいか?
{
    obj.doSomething(); // もう一回呼ぶ
    verify(obj, times(1)).firedMyEvent(); // またここで発火していたことを確認したいが...?
}

しかし上記のテストは失敗する。obj自身はinvocationのカウンタを持ち続けているのでtimes(1)ではなくtimes(2)になってしまうわけだ。
安直に org.mockito.Mockito.reset でリセットしてしまうと、冒頭のdoNothingでmockした箇所までリセットしてしまい面倒だ。

カウンタだけリセットし、また1からカウントするには以下のようにすればよい:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent();
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent();
}
clearInvocations(obj); // org.mockito.Mockito.clearInvocations
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent(); // OK
}

clearInvocationsの引数はvarargsになっているので、第2、第3と引数へどんどん繋げていって一括リセットできる。