Android Studio のログにソースコードへのリンクを表示する

概要

Android Studio のログ出力にソースコードへのリンクを表示する方法です。

目次

確認環境

  • AndroidStudio 3.5.2
  • Kotlin 1.3.50

参考情報

解説

Android Studio のログ出力では、クラス名.メソッド名(ファイル名:行数) の形式で表示したときにソースコードへのリンクが有効になります。 Logcat, Instrumentation Test の実行ログでリンクが有効になることを確認しています。

fun printLinkToCode(tag: String) {
    val clazz = object {}::class.java
    val enclosingClassName = requireNotNull(clazz.enclosingClass?.name)
    val enclosingMethodName = requireNotNull(clazz.enclosingMethod?.name)

    val stackTrace = Thread.currentThread().stackTrace
    val indexOfThisMethod = stackTrace.indexOfFirst {
        it.className == enclosingClassName && it.methodName == enclosingMethodName
    }

    val caller = stackTrace[indexOfThisMethod + 1]
    Log.i(tag, "${caller.className}.${caller.methodName}(${caller.fileName}:${caller.lineNumber})")
}

Zero Caliber VR を Oculus Quest でプレイする

概要

SteamVR で配信されている Zero Caliber VR を Qculus Quest でプレイする方法です。Virtual Desktop を使用しています。

目次

確認環境

  • Virtual Desktop 1.6.2 (SIdeloading)
  • Zero Caliber VR (2019/10/14 時点のバージョン)

解説

SteamVR のアプリを Oculus Quest で実行する方法については、他の記事を参照してください。Zero Caliber VR を Oculus Quest で起動できた前提で話しを進めます。

2019/10/14 現在、Zero Caliber VR を Oculus Quest で実行すると Oculus Touch の操作を何も受け付けてくれません。この問題は SteamVR の Controller Settings から設定を行うことで解決できます。

Current Binding の Edit を選択すると Zero Caliber VR の Action を設定できます。少々面倒ですが、各ボタンに Action を割り当てます。私は下記の割り当てでチュートリアルまでクリアできました。

  • Left Oculus Touch
    • Trigger
      • Click : TriggerLeft
      • Pull : TriggerAxisLeft
    • Joystick
      • Click : TurnLeft (座ってプレイする場合)
      • Position : MotionControllerThumbLeft
    • Grip
      • Click : GripLeft
    • X Button
      • Click : Open Console (お好みで)
    • Y Button
      • Click : MenuLeft (お好みで)
  • Right Oculus Touch
    • Trigger
      • Click : TriggerRight
      • Pull : TriggerAxisRight
    • Joystick
      • Click : TurnRight (座ってプレイする場合)
      • Touch : Run (お好みで)
      • Position : MotionControllerThumbRight
    • Grip
      • Click : GripRight
    • A Button
      • Click : Skill_ReleaseManazine (お好みで)
    • B Button
      • Click : Skill_FireModeChange (お好みで)
  • 未割り当て
    • Skill_RelaseSlider
    • ControllerMovementLeft
    • ControllerMovementRight

立ってプレイする場合には、自分が振り向けば良いので TurnLeft/Right は不要です。立ってプレイしないとしゃがめないので被弾してしまう場面があり、立ってプレイすることは必須なのですが...。

設定は保存できるので保存を推奨します。次回起動時、設定がクリアされてしまいます。設定を保存しておかないと、設定をやり直すことになります。

Room の使い方

概要

Android Kotlin Fundamentals 06.1 (Room) の備忘録です。ポイントとなるコードのみを抜粋しています。

目次

確認環境

  • AndroidStudio 3.5
    • compileSdkVersion 28
    • minSdkVersion 19
  • Gradle 5.4.1
  • Kotlin 1.3.11

参考情報

解説

build.gradle

buildscript {

    ext {
        kotlin_version = '1.3.11'
        archLifecycleVersion = '1.1.1'
        room_version = '2.0.0'
        coroutine_version = '1.0.0'
        gradleVersion = '3.3.0'
        navigationVersion = '1.0.0-alpha08'
        dataBindingCompilerVersion = gradleVersion // Always need to be the same.
    }

    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath "com.android.tools.build:gradle:$gradleVersion"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
    }
}

...

app/build.gradle

...

dependencies {
    ...
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
}

SleepNight.kt

...

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
        @PrimaryKey(autoGenerate = true)
        var nightId: Long = 0L,

        @ColumnInfo(name = "start_time_milli")
        val startTimeMilli: Long = System.currentTimeMillis(),

        @ColumnInfo(name = "end_time_milli")
        var endTimeMilli: Long = startTimeMilli,

        @ColumnInfo(name = "quality_rating")
        var sleepQuality: Int = -1
)

SleepDatabaseDao.kt

...

@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}
  • アノテーションは Insert, Update, Delete, Query の 4 つ
  • LiveData を戻り値にするとデータベースのデータが変更された場合に変更を通知する
    • (変更の検知はデータベース単位?テーブル単位?便利だけどパフォーマンスは問題にならない?)

SleepDatabase.kt

...

@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {

    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object {
        @Volatile
        private var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase {
            synchronized(this) {
                var instance = INSTANCE
                if (instance == null) {
                    instance = Room.databaseBuilder(context.applicationContext, SleepDatabase::class.java, "sleep_history_database")
                            .fallbackToDestructiveMigration()
                            .build()
                    INSTANCE = instance
                }
                return instance
            }
        }
    }
}

SleepDatabaseTest.kt

...

@RunWith(AndroidJUnit4::class)
class SleepDatabaseTest {

    private lateinit var sleepDao: SleepDatabaseDao
    private lateinit var db: SleepDatabase

    @Before
    fun createDb() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
                .allowMainThreadQueries()
                .build()
        sleepDao = db.sleepDatabaseDao
    }

    @After
    @Throws(IOException::class)
    fun closeDb() {
        db.close()
    }

    @Test
    @Throws(Exception::class)
    fun insertAndGetNight() {
        val night = SleepNight()
        sleepDao.insert(night)
        val tonight = sleepDao.getTonight()
        assertEquals(tonight?.sleepQuality, -1)
    }
}
  • inMemoryDatabaseBuilder を使うことでテスト毎にデータベースをクリアする
  • allowMainThreadQueries を実行することでデータベースへの問い合わせを MainThread で実行できる様にする
    • 通常は MainThread でデータベースに問い合わせるとエラーになる

Navigation component の使い方

概要

Android Kotlin Fundamentals 03.x (Fragment と Navigation) の備忘録です。ポイントとなるコードのみを抜粋しています。

目次

確認環境

  • AndroidStudio 3.5
    • compileSdkVersion 28
    • minSdkVersion 19
  • Gradle 5.4.1
  • Kotlin 1.3.11

参考情報

解説

build.gradle

buildscript {
    ext {
        kotlin_version = '1.3.11'
        archLifecycleVersion = '1.1.1'
        gradleVersion = '3.5.0'
        supportlibVersion = '1.0.0-rc03'
        navigationVersion = '1.0.0-rc02'
        dataBindingCompilerVersion = gradleVersion // Always need to be the same.
    }
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:$gradleVersion"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
    }
}
  • Safe Args Gradle plugin を classpath に追加

app/build.gradle

...
apply plugin: 'androidx.navigation.safeargs'
...
dependencies {
    ...
    implementation "android.arch.navigation:navigation-fragment-ktx:$navigationVersion"
    implementation "android.arch.navigation:navigation-ui-ktx:$navigationVersion"
    ...
}
  • Safe Args Gradle plugin を有効化
  • Navigation components 関連のライブラリを追加

activity_main.xml

...
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.drawerlayout.widget.DrawerLayout
        android:id="@+id/drawerLayout"
        ...>

        <!-- サンプルでは LinearLayout の子になっている -->
        <fragment
            android:id="@+id/myNavHostFragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            app:navGraph="@navigation/navigation"
            app:defaultNavHost="true"
            ... />

        <com.google.android.material.navigation.NavigationView
            android:id="@+id/navView"
            android:layout_gravity="start"
            app:headerLayout="@layout/nav_header"
            app:menu="@menu/navdrawer_menu"
            ... />

    </androidx.drawerlayout.widget.DrawerLayout>
</layout>
  • Drawer を使用しない場合は DrawerLayout は不要
  • Navigation components を使って Fragment を切り替えるため NavHostFragment を使用
  • defaultNavHost を true に設定した Fragment が Back ボタンをインターセプト
  • NavigationView は Drawer に表示するナビゲーション
...
<navigation
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/navigation"
    app:startDestination="@id/titleFragment">

    <fragment
        android:id="@+id/titleFragment"
        android:name="com.example.android.navigation.TitleFragment"
        android:label="fragment_title"
        tools:layout="@layout/fragment_title" >
        <action
            android:id="@+id/action_titleFragment_to_gameFragment"
            app:destination="@id/gameFragment" />
    </fragment>

    <fragment
        android:id="@+id/gameFragment"
        android:name="com.example.android.navigation.GameFragment"
        android:label="fragment_game"
        tools:layout="@layout/fragment_game" >
        <action
            android:id="@+id/action_gameFragment_to_gameOverFragment"
            app:destination="@id/gameOverFragment"
            app:popUpTo="@+id/gameFragment"
            app:popUpToInclusive="true" />
        <action
            android:id="@+id/action_gameFragment_to_gameWonFragment"
            app:destination="@id/gameWonFragment"
            app:popUpTo="@+id/gameFragment"
            app:popUpToInclusive="true" />
    </fragment>

    <fragment
        android:id="@+id/gameOverFragment"
        android:name="com.example.android.navigation.GameOverFragment"
        android:label="fragment_game_over"
        tools:layout="@layout/fragment_game_over" >
        <action
            android:id="@+id/action_gameOverFragment_to_gameFragment"
            app:destination="@id/gameFragment"
            app:popUpTo="@+id/titleFragment"
            app:popUpToInclusive="false" />
    </fragment>

    <fragment
        android:id="@+id/gameWonFragment"
        android:name="com.example.android.navigation.GameWonFragment"
        android:label="fragment_game_won"
        tools:layout="@layout/fragment_game_won" >
        <action
            android:id="@+id/action_gameWonFragment_to_gameFragment"
            app:destination="@id/gameFragment"
            app:popUpTo="@+id/titleFragment"
            app:popUpToInclusive="false" />
        <argument
            android:name="numQuestions"
            app:argType="integer" />
        <argument
            android:name="numCorrect"
            app:argType="integer" />
    </fragment>

    <fragment
        android:id="@+id/aboutFragment"
        android:name="com.example.android.navigation.AboutFragment"
        android:label="fragment_about"
        tools:layout="@layout/fragment_about" />
    <fragment
        android:id="@+id/rulesFragment"
        android:name="com.example.android.navigation.RulesFragment"
        android:label="fragment_rules"
        tools:layout="@layout/fragment_rules" />
</navigation>
  • tools:layout は Design editor でレイアウトを表示するために指定
  • app:popUpTo は Back 時の遷移先
  • app:popUpToInclusive が false の場合は popUoTo の Fragment に遷移する
  • app:popUpToInclusive が true の場合は popUoTo の Fragment もバックスタックから除かれ、もう一つ前の Fragment に遷移する
    • titleFragment → gameFragment → gameOverFragment と順にスタックに積まれているので、popUpTo=gameFragment , inclusive=true だと titleFragment に遷移する
  • Safe Args が有効の場合は XXXFragmentDirections クラス (NavDirections を実装) が生成される
    • argument を指定すると XXXFragmentArgs クラスが生成され、遷移元の NavDirections のファクトリメソッドに引数が追加される
...
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/rulesFragment"
        android:title="@string/rules" />
    <item
        android:id="@+id/aboutFragment"
        android:title="@string/about" />
</menu>
  • item の id と navigation.xml で指定した fragment の id を合わせるとライブラリが良きに計らってくれる

MainActivity.kt

class MainActivity : AppCompatActivity() {

    private lateinit var drawerLayout: DrawerLayout

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
        drawerLayout = binding.drawerLayout
        val navController = findNavController(R.id.myNavHostFragment)
        NavigationUI.setupWithNavController(binding.navView, navController)
        NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)
    }

    override fun onSupportNavigateUp(): Boolean {
        return NavigationUI.navigateUp(findNavController(R.id.myNavHostFragment), drawerLayout)
    }
}
  • setupWithNavController はスライド操作で Drawer を表示するために必要
  • setupActionBarWithNavController はアクションバーで Drawer の表示を行うために必要
  • onSupportNavigateUp メソッドはアクションバーで戻る操作を行うために必要

TitleFragment.kt

class TitleFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        val binding = DataBindingUtil.inflate<FragmentTitleBinding>(inflater, R.layout.fragment_title, container, false)
        binding.playButton.setOnClickListener { view ->
            view.findNavController().navigate(TitleFragmentDirections.actionTitleFragmentToGameFragment())
        }
        setHasOptionsMenu(true)
        return binding.root
    }

    override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
        super.onCreateOptionsMenu(menu, inflater)
        inflater?.inflate(R.menu.options_menu, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        return NavigationUI.onNavDestinationSelected(item!!, view!!.findNavController())
                || super.onOptionsItemSelected(item)
    }
}
  • navigate メソッドの引数は navigation.xml における action の id でもよい
  • Safe Args を使用している場合は navigate メソッドの引数に NavDirections を使用できる
  • onNavDestinationSelected は menu の item id から navigation の fragment id を特定して遷移する

GameFragment.kt

class GameFragment : Fragment() {
    ...

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        ...
                        view.findNavController().navigate(
                                GameFragmentDirections.actionGameFragmentToGameWonFragment(
                                        numQuestions,
                                        questionIndex
                                ))
        ...
    }

    ...
}
  • Safe Args を使用しており、navigation の fragment に argument が指定されている場合には NavDirections のファクトリメソッドに引数が追加される

GameWonFragment.kt

class GameWonFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        ...
        val args = GameWonFragmentArgs.fromBundle(arguments!!)
        Toast.makeText(context, "NumCorrect: ${args.numCorrect}, NumQuestions: ${args.numQuestions}", Toast.LENGTH_LONG).show()
        setHasOptionsMenu(true)
        return binding.root
    }

    private fun getShareIntent() : Intent {
        val args = GameWonFragmentArgs.fromBundle(arguments!!)
        return Intent(Intent.ACTION_SEND)
                .setType("text/plain")
                .putExtra(Intent.EXTRA_TEXT, getString(R.string.share_success_text, args.numCorrect, args.numQuestions))
    }

    private fun shareSuccess() {
        startActivity(getShareIntent())
    }

    override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
        super.onCreateOptionsMenu(menu, inflater)
        inflater?.inflate(R.menu.winner_menu, menu)
        if (getShareIntent().resolveActivity(activity!!.packageManager) == null) {
            menu?.findItem(R.id.share)?.isVisible = false
        }
    }

    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        when (item?.itemId) {
            R.id.share -> shareSuccess()
        }
        return super.onOptionsItemSelected(item)
    }
  • XXXFragmentArgs.fromBundle を使えば型安全を得た状態で値を受け取れる
  • onCreateOptionsMenu では ACTION_SEND に対応する Activity の存在を検証し、存在しない場合はメニューを非表示にする

無料のボイスチェンジャーを色々と試した

概要

無料のボイスチェンジャーをいくつか試した結果、VSTHost + RoVee に落ち着きました。OBS Studio と合わせて使うことを前提にしています。

目次

参考情報

解説

まずは参考情報のサイトを参照しつつ、試してみるボイスチェンジャーのソフトウェアをピックアップしました。

今回試したソフトウェアは以下の

試行

恋声

恋声は以前にも別記事で使用しています。2019年現在、最終更新は2018年になっています。

まず、恋声で良いなと思う点です。

  • インストール不要で手軽に使える
  • 男声→女声の初期設定がある
  • 声の高さ(ピッチ)と声の性質(フォルマント)の設定の値の幅が広い
    • 自分の声質(低い声)でも女声っぽくできた

しかし、不満に思う点もあります。

  • 動作が不安定

これが私としては致命的な欠点であり、使用を躊躇する点です。

Gachikoe! Core

2019年現在、絶賛開発中のボイスチェンジャーです。

良いなと思う点は以下です。

  • インストール不要で手軽に使える
  • 設定項目が少なく簡潔
  • 低遅延、高音質らしい
  • 安定して動作する

長時間使用したわけではないので、安定して動作すると言っても恋声と比較しての話です。恋声は短時間の使用でも、動作が不安定になることが頻繁にありました。

恋声に思っていた致命的な欠点は改善されますが、再び致命的な欠点があります。

  • 設定の幅が狭い
    • 自分の声質(低い声)だと女声にはならない

今後に期待のボイスチェンジャーですが、現状では私のニーズを満たすものではありません。低遅延、高音質だとしても、作り出される声がそもそもニーズに満たないと仕方がなく、次へ。

バ美声

2019年現在、こちらも絶賛開発中のボイスチェンジャーです。

良いなと思う点は Gachikoe! Core と同じです。そして、欠点も同じです。Gachikoe! Core とそっくりなコンセプトだと思いました。

こちらも今後に期待のボイスチェンジャーですが、現状では私のニーズを満たすものではなく、次を試すことにしました。

RoVee

2019年現在、最終更新が2013年となっており既に更新が止まっている様子です。他の3つのソフトウェアは単体で動作するソフトウェアでしたが、RoVeeはVSTプラグインとなっており単体では動作しません。OBS StudioにはVSTプラグインによって音声変換を拡張する機能があります。RoVeeをOSB Studioに組み込んでの使用を考えました。

試してみたところ、OSB StudioでマイクのフィルタにRoVeeを表示できましたが、設定を行うウィンドウが表示されません。設定を変更できなければ使い物になりません。調べているとOSB StudioでRoVeeを使っている方もいる様ですが、設定の変更方法までは分かりませんでした。

OSB Studioで直接使うことは断念し、VSTプラグインを組み合わせて音声変換を行うVSTHostを使うことにしました。しかし、VSTHostの公式ダウンロードサイトが表示できません。非公式と思われるサイトは見つかりました。安全性に不安はありますが、下記のサイトからダウンロードできます。

VSTHostでRoVeeを読み込ませることで音声を変換できました。

この組み合わせの良いなと思う点は以下です。

不満に思う点と言えば下記くらい。

VSTHostは今後にメンテナンスされることがなく、使えなくなるかもしれないという不安はあります。その頃には、現在開発中のボイスチェンジャーが機能拡張されていることを祈ります。

まとめ

VSTHostを使えば複数のVSTプラグインを組み合わせて様々な変換を行えます。男声を女声にするだけでなく、複数の声を混ぜたり、エコーをかけたりと組み合わせ次第では無限です。あまり注意していませんでしたが、変換による遅延もそれほど感じませんでした。無料でありながら、安定して動作しつつ、カスタマイズの自由度があり自分の声にも対応できそうだと思います。

リモートデスクトップ接続でゲームコントローラーを使う

概要

ゲームマシンを遠隔で操作してプレイする方法です。ゲームコントローラーを使ってプレイします。

目次

確認環境

参考情報

解説

構成

構築した環境は次の図のとおりです。

ゲームマシンとクライアントマシンが WI-Fi で通信を行い、ゲームコントローラーとクライアントマシンが Bluetooth で通信を行っています。プレイヤーはクライアントマシンの画面を見ながらゲームコントローラーを操作します。しかし、ゲームコントローラーは Windows 用ですので macOS には接続できません。そのため、macOSVMware Fusion をインストールして Windows の仮想クライアントマシンを動かし、仮想クライアントマシンにゲームコントローラーを接続します。仮想クライアントマシンとゲームマシンはリモートデスクトップ接続します。つまり、ゲームコントローラーは、クライアントマシン (macOS) を経由して仮想クライアントマシン (Windows 10) に接続し、さらにリモートデスクトップ接続を経由してゲームマシンに接続します。

環境構築手順

ゲームコントローラーのドライバをインストールする

ゲームコントローラJC-U4113S のドライバをダウンロードしてインストールします。インストールする先は、仮想クライアントマシンとゲームマシンです。

クライアントマシンに Bluetooth のドングルを接続し、仮想クライアントマシン (WIndows 10) に接続させます。ゲームコントローラーは X Input で起動します。そうすると、コントロールパネルのデバイスとプリンターに JC-U4113S が表示されます。

リモートデスクトップ接続のポリシー設定を変更する

リモートデスクトップ接続でゲームコントローラーを共有するためにグループポリシーを編集します。編集は、仮想クライアントマシンとゲームマシンで行います。

gpedit を検索するとグループポリシーの選択が表示されます。

選択するとポリシーエディターが開きます。

コンピューターの構成 > 管理用テンプレート > Windows コンポーネント > リモートデスクトップサービスと辿って、次に示す項目を編集します。

編集を終えたら、コマンドプロンプトを管理者として実行して次に示すコマンドを実行します。

gpupdate /force

成功したら、マシンを再起動します。

ゲームコントローラーをゲームマシンに接続する

仮想クライアントマシンでリモートデスクトップ接続を行い、ゲームマシンに接続します。この際、オプションの表示 > ローカルリソース > 詳細 と開いていきます。リモートデスクトップ接続の設定が適切に行えていれば、ゲームコントローラーを仮想クライアントマシンに接続している状態で下記の様に XBOX 360 Controller が表示されます。

XBOX 360 Controller を選択した上で接続を開始します。ゲームマシンで、コントロールパネルのデバイスとプリンターに JC-U4113S が表示されていれば成功です。ゲームコントローラーの設定 > プロパティと辿れば、ゲームコントローラーのテストができます。

おわりに

私の環境では macOS に仮想クライアントマシンを構築して Windows 10 を動かしていますが、もちろん macOS は無くても構いません。Windows 10 のクライアントマシンからゲームマシンを操作する構成でも可能なはずです。

Android でメールを送信する

概要

メールクライアントアプリを使わずに、自身が開発したアプリでメールを送信する方法です。Google のサービスを使ってメールを送信します。1つの方法は GmailSMTP を使い、もう1つの方法は Google APIGmail サービスを使います。

目次

確認環境

  • macOS 10.13
  • AndroidStudio 3.2.1
  • Gradle 4.6
  • Kotlin 1.3.0

参考情報

解説

認証方法の比較

GmailSMTP を使う方法と Google APIGmail サービスを使う方法の両方ともに、認証が必要になります。認証方法には3つの方法があります。以下のとおりです。

  • GmailSMTP を使う場合
    • Gmail アカウントのパスワードで認証する
    • アプリパスワードで認証する
      • 2段階認証を有効にしている場合
  • Google APIGmail サービスを使う場合
    • Android の機能で認証して、OAuth2 で認可する

GmailSMTP をパスワード認証で使う(非推奨)

この方法は実装方法が最も簡潔な方法です。しかし、使わない方が良い方法です。なぜなら、ユーザーのセキュリティリスクを高めてしまうためです。詳しくは後述します。

まず、app モジュールの build.gradle に JavaMail for Android への依存を追加します。

dependencies {
    //...
    implementation 'com.sun.mail:android-mail:1.5.5'
    implementation 'com.sun.mail:android-activation:1.5.5'
}

AndroidManifest.xml に INTERNET の使用権限を追加します。

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

そして、メール送信を行う処理です。通信は Main (UI) スレッドで行えないため、AsyncTask を使っています。

package com.example.sendmail

import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage
import android.os.AsyncTask
import java.util.*
import javax.mail.*

class SendMail(
    private val fromAddress: String,
    private val password: String,
    private val toAddress: String,
    private val subject: String,
    private val text: String
) : AsyncTask<Unit, Unit, Unit>() {

    override fun doInBackground(vararg params: Unit?) {
        val props = Properties().apply {
            putAll(mapOf(
                "mail.smtp.host" to "smtp.gmail.com",
                "mail.smtp.socketFactory.port" to "465",
                "mail.smtp.socketFactory.class" to "javax.net.ssl.SSLSocketFactory",
                "mail.smtp.auth" to "true",
                "mail.smtp.port" to "465"
            ))
        }

        val session = Session.getDefaultInstance(props, object : Authenticator() {
            override fun getPasswordAuthentication() = PasswordAuthentication(fromAddress, password)
        })

        val message = MimeMessage(session).also {
            it.setFrom(InternetAddress(fromAddress))
            it.addRecipient(Message.RecipientType.TO, InternetAddress(toAddress))
            it.subject = subject
            it.setText(text)
        }

        Transport.send(message)
    }
}

Kotlin の文法に詳しくない方のための補足説明:
クラス名直後の () はコンストラクタの引数です。val と指定されているため、クラスのプロパティでもあります、プロパティは、フィールドと getter/setter を合わせたものです。Java におけるフィールドの定義と this.fromAddress = fromAddress; の様なコンストラクタの引数をフィールドに代入する処理を行っています。Java ではフィールド定義、コンストラクタ定義と分かれているところを Kotlin では1つにまとめて書けます。: AsyncTask で AsyncTask クラスを継承しています。継承すると共に、AsyncTask<...>() として AsyncTask クラスの引数なしコンストラクタを SendMail オブジェクトの生成時に呼び出しています。変数 props には Properties オブジェクトが代入されます。但し、Properties オブジェクトには apply 関数の {} 内の処理が適用されます。object : Authenticator() では Authenticator クラスを継承した無名クラスのオブジェクトを生成しています。MimeMessege(session).alsoMimeMessege(session).apply とほぼ同じなのですが、apply の場合は this.subject = subject となり、also の場合は it.subject = subject になります。apply の場合は subjectthis.subject が同じ変数を指してしまうため、ここでは also を使っています。詳しくは Reference - Kotlin Programming Language を参照してください。


最後に SendMail の呼び出しです。適宜、引数の内容は書き換えてください。

class MainActivity : AppCompatActivity() {

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

        val task = SendMail(
            fromAddress = "your_gmail_account@gmail.com",
            password = "<your password>",
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        )
        task.execute()
    }
}

以上で完成です。アプリを実行させてみると、残念ながら LogCat に以下のエラーが表示されます。

javax.mail.AuthenticationFailedException: 534-5.7.14

エラーメッセージには https://support.google.com/mail/answer/78754 を見るように書かれています。読んでみると、安全性の低いアプリを許可する必要があることが分かります。許可は https://myaccount.google.com/lesssecureapps で行えます。有効に設定した上で、再びアプリを実行してみましょう。今度はメールが送信され、指定のアドレスに届いているはずです。届いていない場合は、パスワードが間違えていないか確認しましょう。

しかし、安全性の低いアプリの許可を有効にすると、様々な怪しいアプリにも許可を与えることになります。これは危険です。先程のヘルプには、2段階認証を設定し、アプリパスワードを使う方法があると書かれています。この方法は使わずに、アプリパスワードを使う方法を使います。先に進む前に、安全性の低いアプリの許可は無効に戻しておきましょう。

GmailSMTP をアプリパスワードで使う

では、2段階認証を有効にし、アプリパスワードを使ってメールを送信してみます。

https://myaccount.google.com/lesssecureapps のページの左向き矢印を選択すると、ログインとセキュリティの設定ページに移動します。パスワードとログイン方法の項目に、2段階認証プロセスの項目があります。手順に従って有効にしましょう。

手順どおりに進めて、次の画面が表示されたら2段階認証プロセスの設定は完了です。

左向き矢印を選択して、メニュー階層を戻りましょう。2段階認証プロセスを有効にすると、アプリパスワードの項目が追加されます。

アプリパスワードを追加します。メールを選択し、端末は Android が見当たらないのでその他を選択します。端末名の入力では Android とでも入力しておきます。

生成を行うと、黄色い枠のなかにアプリパスワードが表示されます。(黒塗りして隠しています。)

このアプリパスワードをコピーし、Gmail アカウントのパスワードと差し替えます。

class MainActivity : AppCompatActivity() {

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

        val task = SendMail(
            fromAddress = "your_gmail_account@gmail.com",
            password = "<your application password>", // 差し替え
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        )
        task.execute()
    }
}

アプリを実行してみましょう。メールが送信され、指定のアドレスに届いているはずです。

Google APIGmail サービスを OAuth2 で使う

アプリパスワードを使う方法では、ユーザーにアプリパスワードの設定を行ってもらう必要があります。限られた環境のみで使うのであれば、手軽な方法ではあります。多くのユーザーが使うことを想定すると、使い勝手の観点から現実的ではありません。そこで、Android の機能で認証を行い、Google APIGmail サービスを OAuth2 で認可する仕組みを使います。

まずは、app モジュールの build.gradle にライブラリへの依存を追加します。

dependencies {
    // ...
    implementation 'com.sun.mail:android-mail:1.5.5'

    implementation('com.google.android.gms:play-services-auth:16.0.1') {
        exclude group: 'com.android.support'
    }
    implementation 'com.google.api-client:google-api-client-android:1.25.0'
    implementation 'com.google.api-client:google-api-client-gson:1.25.0'
    implementation 'com.google.apis:google-api-services-gmail:v1-rev96-1.25.0'
}

android-mail は無くても構いませんが、使うと Message (後述) の構築が楽です。play-services-auth では com.android.support を除外しています。Support Library への依存を別途追加する場合に、バージョンの違いでエラーになるため除外しています。

AndroidManifest.xml には INTERNET の使用権限を追加します。

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

そして、メール送信を行う処理です。通信は Main (UI) スレッドで行えないため、ここでも AsyncTask を使っています。

package com.example.sendmail

import android.content.Intent
import android.os.AsyncTask
import com.google.api.client.extensions.android.http.AndroidHttp
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import com.google.api.client.json.gson.GsonFactory
import com.google.api.client.repackaged.org.apache.commons.codec.binary.Base64
import com.google.api.services.gmail.Gmail
import com.google.api.services.gmail.model.Message
import java.io.ByteArrayOutputStream
import java.util.*
import javax.mail.Session
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage

class SendMailUsingGmailService(
    private val credential: GoogleAccountCredential,
    private val message: MimeMessage,
    private val onAuthError: (Intent) -> Unit
) : AsyncTask<Unit, Unit, Intent?>() {

    companion object {
        private fun createMimeMessage(
            fromAddress: String,
            toAddress: String,
            subject: String,
            text: String
        ): MimeMessage {
            val session = Session.getDefaultInstance(Properties())
            return MimeMessage(session).also {
                it.setFrom(InternetAddress(fromAddress))
                it.addRecipient(javax.mail.Message.RecipientType.TO, InternetAddress(toAddress))
                it.subject = subject
                it.setText(text)
            }
        }
    }

    constructor(
        credential: GoogleAccountCredential,
        toAddress: String,
        subject: String,
        text: String,
        onAuthError: (Intent) -> Unit
    ) : this(
        credential,
        createMimeMessage(credential.selectedAccountName, toAddress, subject, text),
        onAuthError
    )

    override fun onPostExecute(result: Intent?) {
        result?.also(onAuthError)
    }

    override fun doInBackground(vararg params: Unit?): Intent? {
        return try {
            buildService(credential).send(message)
            null
        } catch (e: UserRecoverableAuthIOException) {
            e.intent
        }
    }

    private fun buildService(credential: GoogleAccountCredential): Gmail {
        val transport = AndroidHttp.newCompatibleTransport()
        return Gmail.Builder(transport, GsonFactory(), credential)
            .setApplicationName("Send Mail")
            .build()
    }

    private fun Gmail.send(mimeMessage: MimeMessage) {
        users()
            .messages()
            .send(credential.selectedAccountName, createMessage(mimeMessage))
            .execute()
    }

    private fun createMessage(mimeMessage: MimeMessage): Message {
        return Message().apply {
            val bytes = ByteArrayOutputStream()
                .also(mimeMessage::writeTo)
                .toByteArray()
            raw = Base64.encodeBase64URLSafeString(bytes)
        }
    }
}

Kotlin の文法に詳しくない方のための補足説明:
クラス名直後の () はプライマリコンストラクタの引数です。SendMailUsingGmailService クラスには2つのコンストラクタがあります。constructor で始まる部分はセカンダリコンストラクタと呼ばれ、this(...) でプライマリコンストラクタを呼び出しています。companion object の {} 内に関数を書くとクラスメソッドとして振る舞います。result?.also(onAuthError) は、result が null では無いときのみ onAuthError(result) を実行する書き方です。? を書かない場合は null チェックを行わずに onAuthError(result) を実行します。try-catch 構文は try, catch のブロック内の最後の値が try-catch 式の値となって、その値が return により関数の戻り値にされます。具体的には、UserRecoverableAuthIOException が発生した場合は e.intent が戻り値となり、成功した場合は null が戻り値になります。fun Gmail.send(...) は拡張関数と呼ばれ、Gmail クラスに send メソッドが追加された様に見せかけることができます。そのため、buildService(credential).send(message) という書き方ができています。buildService の戻り値は Gmail オブジェクトですので、拡張関数である send メソッド(関数)を呼び出せる様になっています。 詳しくは Reference - Kotlin Programming Language を参照してください。


さて、本題に戻って処理の内容です。Google アカウントで認証した結果が GoogleAccountCredential として渡されます。メールのコンテンツは MimeMessage として扱われます。buildService 関数では Gmail サービスを準備し、send 関数でサービスを利用しています。createMessage 関数では MimeMessage をバイト列に変換して、 API に送信するメッセージ本文の raw にそのバイト列を設定しています。

SendMailUsingGmailService の呼び出しは Google アカウントを使った認証と OAuth2 による認可の手順が必要になるため複雑になります。

package com.example.sendmail

import android.accounts.AccountManager
import android.app.Activity
import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.services.gmail.GmailScopes

class MainActivity : AppCompatActivity() {

    companion object {
        private const val REQUEST_ACCOUNT_CHOOSER = 1
        private const val REQUEST_AUTHORIZATION = 2
    }

    private lateinit var mCredential: GoogleAccountCredential

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

        mCredential = GoogleAccountCredential.usingOAuth2(this, listOf(GmailScopes.GMAIL_SEND))
        startActivityForResult(mCredential.newChooseAccountIntent(), REQUEST_ACCOUNT_CHOOSER)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            REQUEST_ACCOUNT_CHOOSER -> {
                if (resultCode == Activity.RESULT_OK && data != null) {
                    val accountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
                    Log.i(localClassName, "Account Name: $accountName")
                    if (accountName != null) {
                        mCredential.selectedAccountName = accountName
                    }
                    sendMail()
                }
            }
            REQUEST_AUTHORIZATION -> sendMail()
        }
    }

    private fun sendMail() {
        val task = SendMailUsingGmailService(
            mCredential,
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        ) {
            startActivityForResult(it, REQUEST_AUTHORIZATION)
        }

        task.execute()
    }
}

Kotlin の文法に詳しくない方のための補足説明:
val は再代入不可な変数、var は再代入可能な変数です。lateinit は後で初期化することを示す印です。lateinit を使わない場合には var mCredential: GoogleAccountCredential? = null とします。変数の型に ? が付く場合には、変数に null の代入を許容することになるので変数を使う時に null チェックが必要になります。変数を使う前に必ず null 以外で初期化され、変数を使う時の null チェックを減らしたい場合に lateinit を使います。SendMailUsingGmailService のコンストラクタの引数には onAuthError が必要です。しかし、一見すると () 内には引数が見当たりません。Kotlin では、最後の引数の型が関数である場合 () の外側に記述できます。{} はブロック(複数の文をまとめる構造)、もしくはラムダ式(関数の一種)を表しており、{ startActivityForResult(...) } は関数の一種ということになります。onAuthError 引数にはこの関数が代入されます。詳しくは Reference - Kotlin Programming Language を参照してください。


GmailScopes.GMAIL_SEND を指定して、送信サービスの認可を得ようとしています。startActivityForResult(..., REQUEST_ACCOUNT_CHOOSER) ではアカウントの選択を起動します。アカウントを選択後 onActivityResult の REQUEST_ACCOUNT_CHOOSER の部分が実行されます。選択されたアカウント名を GoogleAccountCredential に設定しています。これにより GoogleAccountCredential が整ったので、メールの送信処理に移ります。メールを送信しようとすると、サービスへのアクセス許可が与えられていない場合に onAuthError コールバックが呼び出されます。コールバックでは startActivityForResult(it, REQUEST_AUTHORIZATION) を呼び出してアクセス許可を与えるかの選択をユーザーに促します。選択の後、onActivityResult の REQUEST_AUTHORIZATION の部分が実行されます。再びメールを送信し、アクセス許可が与えられていたら onAuthError コールバックは呼び出されずに終了します。

以上で、実装は完了しました。アプリを起動してみます。しかし、ビルドの途中で以下のエラーが表示されるかもしれません。

More than one file was found with OS independent path 'META-INF/DEPENDENCIES'

この場合には、build.gradle に以下を追加すれば解決します。(理由は調べられていません。どなたか教えて頂けると嬉しいです。)

android {
    // ...
    packagingOptions {
        exclude 'META-INF/DEPENDENCIES'
    }
}

アプリが起動するとアカウント選択が表示され、いずれかのアカウントを選択します。

そうすると、エラーが発生します。LogCat に以下のエラーが表示されます。

com.google.android.gms.auth.GoogleAuthException: UNREGISTERED_ON_API_CONSOLE

解消するためには Developer Console でサービスを有効化しておく必要があります。

Developer Console でサービスを有効化

まず、Google Cloud Platform から Console を開きます。既存のプロジェクトでサービスを有効化しても良いですが、ここでは新しいプロジェクトを作成します。プロジェクト名は何でも良いので、ここでは SendMail にしておきます。

プロジェクト (SendMail) が選択されていることを確認した上で、API とサービスのメニューを開きます。ここから「API とサービスの有効化」を選択します

Gmail を検索し、Gmail API を選択します。

Gmail API を有効にします。

次に、Gmail API の認証情報を設定します。認証情報メニューを選択し、「認証情報を作成」を選択します。

使用する APIGmail APIAPI を呼び出す場所は Android、アクセスするデータの種類はユーザーデータです。

「必要な認証情報」を選択すると次のステップに進みます。

クライアント ID を作成するにあたっての名前は後で見て分かる名前を付けます。署名証明書フィンガープリントは Terminal で keytool コマンドを使って取得します。

書かれているとおりのコマンドを実行しますが、path-to-debug-or-production-keystore は自分で埋める必要があります。debug-keystore を使う場合は以下のとおりに実行します。(macOS の場合です。)

$ keytool -exportcert -keystore ~/.android/debug.keystore -list -v

パスワードを求められますが、何も入力せずに Enter で先に進めます。表示された情報のなかから、SHA1 のフィンガープリントをコピーします。Android アプリを識別するための情報(指紋)です。配布するアプリの場合には、自分で作成した production-keystore ファイルのパスを指定します。今回は実験用なので debug-keystore を使っています。

パッケージ名は AndroidManifest.xml で指定したパッケージ名を記入します。しかし、build.gradle の applicationId で異なるパッケージ名を設定している場合には applicationId を記入する必要があるようです。私は試していませんが StackOverflow でみかけました。なお、1つの Android アプリを複数のプロジェクトには設定できない様です。異なるアカウントの異なるプロジェクトに同一アプリを設定しようとしたところ、設定できませんでした。

記入を終えて「OAuth クライアント ID を作成」を選択すると次のステップに進みます。

次は、ユーザーに表示される同意画面の設定です。ユーザーに見せるサポート用のメールアドレス、サービス名を設定します。今回は API を使うサービスが Android のアプリだけなので Android アプリの名前を設定しています。サービス名が別途存在する場合には、そちらを設定すれば良いでしょう。

「次へ」を選択すると最後のステップに進みます。Android アプリで使う場合には、認証情報をダウンロードしなくて構いません。完了を選択します。

次に、OAuth 同意画面を設定します。アプリケーション名、サポートメールは先程設定した内容が表示されます。

下にスクロールすると Google API のスコープの設定が現れます。ユーザーにアクセスを許可する範囲の設定です。「スコープを追加」を選択し、gmail.send を探して追加します。

更に下にスクロールすると保存ボタンがあります。個人的な利用の範囲であれば保存で構いません。しかし、一般に公開したい場合には「確認のため送信」を選択する必要があります。公開の前に Google による確認が行われます。今回は省略していますが、プライバシーポリシーの設定などの他の項目も適切に設定した上で確認を依頼しましょう。

以上でサービスの有効化は完了しました。アプリを起動して試してみましょう。Google アカウントの選択までは既に行えていました。

アカウントを選択するとエラーではなく、同意画面が表示されます。

許可を選択することで同意したことになり、gmail.send (GmailScopes.GMAIL_SEND) が有効になります。これで、Gmail サービスを使ったメール送信が行える様になります。

おつかれさまでした。