【JetpackCompose】 TextField の singleLine は物理キーボードの Enter には無力

無力というか、ぱっと見で Enter の挙動について勘違いしてしまうかな、というものです。

JetpackCompose の TextField には singleLine という引数があって、1行しか表示したくない時に利用されます。

var value by remember { mutableStateOf("") }
TextField(
  value = value,
  onValueChange = { value = it },
  singleLine = true
)

この状態で、スマートフォンタブレットにて、ソフトウェアキーボードを使っている時は特に問題なく1行に収まると思うのですが、物理キーボードを接続してEnter キーを押すと改行されてしまい、1行ではなくなってしまいます。

これは正確には、物理キーボード入力でなくとも、改行コードが入ってくると singleLineが true でも改行されます。TextField は改行コードの面倒を見てくれる機能は持ち合わせていないのです。
issuetracker.google.com

なんとかするとすると、入力した文字列から改行をとるという感じになるのでしょうか。
例えば素朴にこんな感じですかね...

var value by remember { mutableStateOf("") }
TextField(
  value = value,
  onValueChange = {
    newValue ->
      value = newValue.filter { it != '\n' }
  },
  singleLine = true
)

一応これで改行は入り込まないですね。

見た目以外の問題として、物理キーボードで Enter が押されたら次の入力欄に移動したい、という要件もありそうです。

                    var id by remember { mutableStateOf("") }
                    var pass by remember { mutableStateOf("") }
                    val (idInputFocus, passInputFocus) = remember { FocusRequester.createRefs() }
                    Column {
                        TextField(
                            value = id,
                            onValueChange = { newValue ->
                                id = newValue.filter { it != '\n' }
                            },
                            modifier = Modifier
                                .onKeyEvent {
                                    if (it.nativeKeyEvent.keyCode == KEYCODE_ENTER) {
                                        passInputFocus.requestFocus()
                                    }
                                    true
                                }
                                .focusRequester(idInputFocus)
                                .focusProperties {
                                    next = passInputFocus
                                },
                        )
                        TextField(
                            value = pass,

これでよいのでしょうか?これではダメで、日本語入力中な場合は Enter で変換を確定するとonKeyEventに拾われてしまいます。なので TextFieldvalue には TextFieldValue を渡すことにして、composition の有無で IME が入力中としている文字列がない場合だけ Enter で次の TextField に進めるにようにしてみました。

                    var id by remember {
                        mutableStateOf(TextFieldValue(text = ""))
                    }
                    val (idInputFocus, passInputFocus) = remember { FocusRequester.createRefs() }
                    Column {
                        TextField(
                            value = id,
                            onValueChange = { newValue ->
                                id = newValue.copy(text = newValue.text.filter { it != '\n' } )
                            },
                            modifier = Modifier
                                .onKeyEvent {
                                    if (it.nativeKeyEvent.keyCode == KEYCODE_ENTER && id.composition == null) { // 入力中ではない
                                        passInputFocus.requestFocus()
                                    }
                                    true
                                }
                                .focusRequester(idInputFocus)
                                .focusProperties {
                                    next = passInputFocus
                                },
                        )
                        TextField(
...

面倒ですが、現時点ではこんな感じでしょうか...

Android Car App Library 1.4.0-alpha01 が登場しました

🎉 面白いものがないかを見てみましょう。
developer.android.com

Adds top-level actions to GridTemplate in Car App Library (Id0191) && Adds top-level actions to ListTemplate in Car App Library (I9efab)

GridTemplateListTemplatetop-level actionsを表示できるようになりました。

top-level actions って言われても意味不明だったのですが、FloatingActionButton として、ボタンを表示できるようになるようです。

GridTemplate.Builder  |  Android Developers


こういう感じに指定できます。

ListTemplate.Builder()
            ...
            .addAction(
                Action.Builder()
                    .setEnabled(true)
                    .setIcon(CarIcon.APP_ICON)
                    .setBackgroundColor(CarColor.PRIMARY)
                    .setOnClickListener { }
                    .build()
            )
            ...

ただ現時点では特に何も表示されないし、デザインガイドラインにも FAB についての記述は見当たらないため、今後のリリースによって表示されるようになる気がします。

メディアアプリにて表示している View にて、アルバム名からアルバムに遷移、アーティスト名からアーティストに遷移できる紐付けが提供されるようです。まだ試してないのでわかりませんが、これできなくて不便に感じている人多いと思うので嬉しいですね。

developer.android.com

Add messaging callbacks to A4C (Ie3986)

これは一体何を言っているのでしょうか?ぱっと見では意味不明でした...
callbacks ということで追加されたコードを見てみると、このような interface があります。

/** Host -> Client callbacks for a {@link ConversationItem} */
@ExperimentalCarApi
@CarProtocol
public interface ConversationCallback {
    /**
     * Notifies the app that it should mark all messages in the current conversation as read
     */
    void onMarkAsRead();

    /**
     * Notifies the app that it should send a reply to a given conversation
     */
    void onTextReply(@NonNull String replyText);
}

conversation(会話)????一体何のことを言っているのでしょうか?これについては、API Level 6 で使用可能なConversationItemクラスを理解する必要があります。

ConversationItem クラスとは何か?

リリースノートには一切記述されていないため気がついていない人が大多数だと思いますが、去年の段階でConversationItem というものが追加されています。
developer.android.com

ドキュメントの説明を見ても簡単すぎていまいちわかりにくいですね。conversation ということで会話を表していそうですが、どういうことでしょうか?

ConversationItem は Grid表示や List表示に使われる interface Item を実装しています。
Item  |  Android Developers

このことから、グリッドやリストに表示するクラスであることがわかります。

次に、ConversationItem は内部に CarMessage を保持します。
developer.android.com

getBody()CarText を取得できますが、これはメッセージと書かれています。getSender() でメッセージを送信した人を取得できるようです。getRead()で「既読かどうか?」の真偽値が返ってきます。このことから、送信されてきたメッセージを表すクラスであろうということがわかります。つまり CarMessage を内部にもつ ConversationItem はどうやらメッセージアプリのリスト or グリッド表示で使用されるようだということがわかります。


試しに ConversationItem をリストに表示してみましょうか。実行時にエラーにならない程度にセットするデータを絞って、可能な限り行数を減らしたコードです。今回追加されている ConversationCallbackインスタンスも登場しています。

...
val itemList = ItemList.Builder()
            .addItem(
                ConversationItem.Builder()
                    .setId("id")
                    .setTitle(CarText.create("title"))
                    .setMessages(listOf(CarMessage.Builder()
                        .setSender(Person.Builder()
                            .setName("name")
                            .build())
                        .setBody(CarText.create("こんにちは。今日の天気はいかがでしょう?"))
                        .build()))
                    .setConversationCallback(object : ConversationCallback {
                        override fun onMarkAsRead() {
                            // do nothing
                        }

                        override fun onTextReply(replyText: String) {
                            // do nothing
                        }
                    })
                    .build()
            )
            .build()

        return ListTemplate.Builder()
            .setSingleList(
                itemList
            )
...

これを実行するとどうなるでしょうか?

リストの項目が表示されています。このリストを選択するとどうなるでしょう?


なんといきなり Google アシスタントが起動し、以下のように話しかけてきます。

Googleアシスタント「こんにちは。今日の天気はいかがでしょう?返信しますか?」
そして返答待ちになります。ここで「はい」というと

Googleアシスタント「はい、メッセージをお願いします。」
となり、メッセージの入力を求められます。このあとは以下のように流れていきます。

人間「こんにちは。こちらは晴れです。早く暖かくなってほしいですね。」

Googleアシスタント「次のメッセージを送信します。こんにちは。こちらは晴れです。早く暖かくなってほしいですね。」

Googleアシスタント「送信しますか?変更しますか?」

人間「送信します」

Googleアシスタント「送信しました。メッセージは以上です。」

つまり、ConversationItem は送られてきたメッセージを「音声で読み上げ、音声入力で返信可能な項目」としてリストに表示し、選択された場合は Google アシスタントによって返信できる機能を提供する、というものであるということがわかりました。だから「会話」なんですね。

メッセージを読み上げたら ConversationCallbackonMarkAsRead が呼ばれ、既読処理をするべきだということがわかるようになっています。onTextReplyGoogleアシスタント「送信しました。メッセージは以上です。」が読み上げられた時点で呼ばれます。ここで返信処理を書く、ということになります(アシスタントが勝手に送ってくれるわけではない)。

おもしろポイントまとめ

  • GridTemplateListTemplate には FAB らしきボタンがつくみたいです
  • リストに表示されたメッセージ項目に対し、ユーザがGoogle アシスタントと会話することで返信できる機能が用意されています

Car App Library の CarAudioRecord を試す

概要

Car App Library 1.3.0 から、車のマイクを使った音声データを処理できるようになりました。 developer.android.com

面白そうなので、サンプルコードを見ながら動かしてみました。コードの大枠はサンプルコードと同じで、所々を適当に都合の良いように改変しています。

準備

Android Auto のアップデート

まず Android Auto を最新のものにアップデートします。CarAudioRecord API を利用するには、Android Auto 8 系が必要です。またこの機能を利用するために Android Auto から API Level 5 以上であるという判定をしてもらう必要があります。

当初自分は Android Auto を 8.1 以降のバージョンにしていたのですが、DHU では API Level 6 になるものの、実際の車では API Level 4 と判定されており、実車CarAudioRecord が使えませんでした。ですが 2023/01/11 にアップデートされた Android Auto 8.6.6 をインストールしたところ、実車でも API Level 5 に到達することができました。

Gradle

CarAudioRecord API を利用するコードを書くために Car App Library は 1.3.0 系にします。2023/02/03 現在だと 1.3.0-rc01 が最新です。

implementation "androidx.car.app:app:1.3.0-rc01"
implementation "androidx.car.app:app-projected:1.3.0-rc01"

AndroidManifest

次に、Android Manifest に音声を扱う旨を記述しておきます。

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

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

従来のマイク録音パーミッションと同じですね。 Manifest.permission  |  Android Developers

Screen クラスの作成

Screen クラスの大枠を作っておきます。REC ボタンを押したら録音が開始されるという目論見です。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
    override fun onGetTemplate(): Template {
        val row = Row.Builder().setTitle("Voice Recoding Screen").build()
        val pane = Pane.Builder().addRow(row).build()
        return PaneTemplate.Builder(pane)
            .setHeaderAction(Action.BACK)
            .setActionStrip(
                ActionStrip.Builder().addAction(
                    Action.Builder()
                        .setTitle("REC")
                        .setOnClickListener { /*TODO*/ }
                        .build()
                ).build()
            )
            .build()
    }
}

起動して確認するとこんな感じです。

録音クラスの作成

VoiceRecorder という、録音をしてくれるクラスを作成したいと思います。

class VoiceRecorder(private val carContext: CarContext) {
  fun record() {}
}

気が早いと思いつつ、これを先ほどの Screen クラスで使用すると、以下のような感じです。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
    private val voiceRecorder = VoiceRecorder(carContext)

    private fun onRecordClicked() {
        voiceRecorder.record()
    }

    override fun onGetTemplate(): Template {
        ...
                ActionStrip.Builder().addAction(
                    Action.Builder()
                        .setTitle("REC")
                        .setOnClickListener { onRecordClicked() }
                        .build()
           ...
}

record() の実装

CarAudioRecordrecord() メソッドを実装して、車のマイクから音声を録音し、データを端末に保存してみましょう!

RECORD_AUDIO 権限チェック

早速 CarAudioRecordインスタンスを作成して録音を実装したいところですが、その前に録音権限チェックを通しておく必要があります。CarAudioRecord のインタンスを作成する create() static 関数は、 @RequiresPermission(RECORD_AUDIO) が付いているからです。

    @RequiresPermission(RECORD_AUDIO)
    @NonNull
    public static CarAudioRecord create(@NonNull CarContext carContext) {

今回はここで権限チェックはせずに、使う側にチェックをお願いしたく @RequiresPermission(permission.RECORD_AUDIO) をつけておきました。これでこのメソッドが呼ばれる時には権限チェックが通っていることが(IDEの警告を無視しなければ)保証されるはずです。

@RequiresPermission(permission.RECORD_AUDIO)
    fun record() {
            val carAudioRecord = CarAudioRecord.create(carContext)
            ....

フォーカスをもらう

developer.android.com Audio Focus をもらいます。どういうことかというと、現在再生中の音楽を止めてフォーカスをもらうコードです。Android で音楽を再生中に「OK Google」というと音楽が止まってマイクから音を拾ってくれると思いますがまさにあれです。

fun record() {
    val carAudioRecord = CarAudioRecord.create(carContext)
    // audio のフォーカスを貰う。
    // 音楽が鳴っていてもここで静音にし録音できる
    val audioAttributes = AudioAttributes.Builder()
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
            .build()
    val audioFocusRequest =
            AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
                .setAudioAttributes(audioAttributes)
                .setOnAudioFocusChangeListener { state: Int ->
                    if (state == AudioManager.AUDIOFOCUS_LOSS) {
                        // 他のアプリによってなど、何かしらの理由でフォーカスが外れた時、録音を終了させる
                        carAudioRecord.stopRecording()
                    }
                }
        .build()
    if (carContext.getSystemService(AudioManager::class.java)
        .requestAudioFocus(audioFocusRequest)
        != AudioManager.AUDIOFOCUS_REQUEST_GRANTED
    ) {
            return
    }

録音を実装する

録音を開始するには、CarAudioRecord クラスの startRecording() メソッドを呼びます。これが呼ばれると、車の画面には録音中の UI が強制的に表示されます。

carAudioRecord.startRecording()

このメソッドがスレッドをブロックすることはありません。

CarAudioRecord クラスの read() メソッドを使って、音声データを読み取って保存してみます。

fun record() {
...
        val bytes: MutableList<Byte> = ArrayList()
        val byteArray = ByteArray(CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE)
        // 録音が停止されない限り、carAudioRecord は音声データを提供し続ける
        while (carAudioRecord.read(byteArray, 0, CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE) >= 0) {
            byteArray.forEach {
                bytes.add(it)
            }
        }

        try {
            val outputStream: OutputStream =
                carContext.openFileOutput("voice.wav", Context.MODE_PRIVATE)
            addHeader(outputStream, bytes.size)
            bytes.forEach {
                outputStream.write(it.toInt())
            }
            outputStream.flush()
            outputStream.close()
        } catch (e: IOException) {
            throw IllegalStateException(e)
        } finally {
            carAudioRecord.stopRecording()
        }

自力で wave フォーマットとして保存するために、wave フォーマットのヘッダを自分でつけています。

private fun addHeader(outputStream: OutputStream, totalAudioLen: Int) {
        val totalDataLen = totalAudioLen + 36
        val header = ByteArray(44)
        val channels = 1
        val dataElementSize = 16
        val blockAlign = (channels * dataElementSize / 8).toLong()
        val longSampleRate = CarAudioRecord.AUDIO_CONTENT_SAMPLING_RATE.toLong() // 16000 なので 16kHz
        val byteRate = longSampleRate * blockAlign

        // RIFF 識別子(4byte)。それぞれを R I F F 文字にする
        header[0] = 'R'.code.toByte()
        header[1] = 'I'.code.toByte()
        header[2] = 'F'.code.toByte()
        header[3] = 'F'.code.toByte()

        // チャンクサイズ(4byte)。
        header[4] = (totalAudioLen and 0xff).toByte() // ファイルサイズ
        header[5] = (totalDataLen shr 8 and 0xff).toByte()
        header[6] = (totalDataLen shr 16 and 0xff).toByte()
        header[7] = (totalDataLen shr 24 and 0xff).toByte()

        // フォーマット(4byte)。wave を表したいので W A V E と入れる。
        header[8] = 'W'.code.toByte()
        header[9] = 'A'.code.toByte()
        header[10] = 'V'.code.toByte()
        header[11] = 'E'.code.toByte()

        // フォーマットチャンク(4byte)。f m t の3文字でよくて、残り余っている 1byte は空白で。
        header[12] = 'f'.code.toByte()
        header[13] = 'm'.code.toByte()
        header[14] = 't'.code.toByte()
        header[15] = ' '.code.toByte()

        // フォーマットサイズ(4byte)。ここではリニア PCM だけを考えればよく、16 を 1byte 目に入れて、残りは 0 で。
        header[16] = 16
        header[17] = 0
        header[18] = 0
        header[19] = 0

        // オーディオフォーマット(2byte)。非圧縮リニア PCM であるため 1 を入れて残りは 0 で。
        header[20] = 1
        header[21] = 0

        // チャンネル数(2byte)。モノラルは 1、ステレオは 2。ここではモノラルを指定している。
        header[22] = channels.toByte()
        header[23] = 0

        // サンプリングレート(4byte)。
        header[24] = (longSampleRate and 0xff).toByte()
        header[25] = (longSampleRate shr 8 and 0xff).toByte()
        header[26] = (longSampleRate shr 16 and 0xff).toByte()
        header[27] = (longSampleRate shr 24 and 0xff).toByte()

        // バイトレート(4byte)。
        header[28] = (byteRate and 0xff).toByte()
        header[29] = (byteRate shr 8 and 0xff).toByte()
        header[30] = (byteRate shr 16 and 0xff).toByte()
        header[31] = (byteRate shr 24 and 0xff).toByte()

        // ブロックのサイズ
        header[32] = blockAlign.toByte()
        header[33] = 0

        // ビットサンプル。
        header[34] = (dataElementSize and 0xff).toByte()
        header[35] = (dataElementSize shr 8 and 0xff).toByte()
        header[36] = 'd'.code.toByte()
        header[37] = 'a'.code.toByte()
        header[38] = 't'.code.toByte()
        header[39] = 'a'.code.toByte()
        header[40] = (totalAudioLen and 0xff).toByte()
        header[41] = (totalAudioLen shr 8 and 0xff).toByte()
        header[42] = (totalAudioLen shr 16 and 0xff).toByte()
        header[43] = (totalAudioLen shr 24 and 0xff).toByte()
        outputStream.write(header, 0, 44)
    }

そして、このままだとスレッドをブロックしてしまうのでコルーチンの利用を考えます。とりあえず丸っと record() 関数を suspend にしてしまいます。

suspend fun record() = runInterruptible {....

Screen で録音する関数を呼び出す

先立って録音ボタンのコールバック onRecordClicked() を仮実装していましたが、ここできちんと実装します。まず gradle に androidx.lifecycle:lifecycle-runtime-ktx を導入してください。

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"

次に Screen を実装してみます。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
...
  private fun onRecordClicked() {
            if (carContext.checkSelfPermission(permission.RECORD_AUDIO)
                != PackageManager.PERMISSION_GRANTED
            ) {
                CarToast.makeText(
                    carContext, "マイクを使用するには権限が必要です",
                    CarToast.LENGTH_LONG
                ).show()
                carContext.requestPermissions(listOf(permission.RECORD_AUDIO)) { grantedPermissions, _ ->
                    if (grantedPermissions.contains(permission.RECORD_AUDIO)) {
                        onRecordClicked()
                    }
                }
                return
            }
        lifecycleScope.launch {
            withContext(Dispatchers.IO) {
                runCatching {
                    voiceRecorder.record()
                }.fold(
                    onSuccess = {
                        CarToast.makeText(
                            carContext, "録音が完了しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    },
                    onFailure = {
                        CarToast.makeText(
                            carContext, "録音に失敗しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    }
                )
            }
        }
    }

順を追って説明していくと、まずは権限があるかどうかをチェックしています。ここで権限がない場合は、ユーザに付与をお願いするダイアログを出すことになります。ダイアログはCarContext.requestPermissions() を使うことで簡単に実装できます。

Using the Android for Cars App Library  |  Android Developers

権限があることが確認できたら voiceRecorder.record() を呼ぶことができます。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
...
  lifecycleScope.launch {
            withContext(Dispatchers.IO) {
                runCatching {
                    voiceRecorder.record()
                }.fold(
                    onSuccess = {
                        CarToast.makeText(
                            carContext, "録音が完了しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    },
                    onFailure = {
                        CarToast.makeText(
                            carContext, "録音に失敗しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    }
                )
            }
        }

ここで lifecycleScope が登場しています。Android Auto の画面に投影する UI の Screen クラスは、Activity や Fragment などと同じく LifecycleOwner インターフェースを実装しています。そのため androidx.lifecyclelifecycleScope が使えるのです。

実行

DHU で実行してみます。

できていそう。

実際にファイルができているか、Android StudioDevice File Explorer で確認してみましょう。

できてますね。PC に転送して聞いてみましょう。

ここでちゃんと聞けていたら成功です。なお再生に失敗したり、砂嵐の音声になっている場合は wave ヘッダの記述ミスを疑ってみてください。公式のサンプル(https://github.com/android/car-samples)では wave ヘッダが間違っているらしく、PC で再生すると砂嵐の音声を聴かされることになります。プルリクを投げてみましたがまだみられていない模様です。

これを実車で動かす場合は、Play Console を使って内部テスト版にして端末にインストールしましょう。Android Auto が車からのマイク入力に対応していれば動くと思います。どの車で動くのかは情報が全然ないので、自分で試してみるのが一番手っ取り早いです。なお筆者の車は MAZDA3 で、動作することを確認しています。

まとめ

  • API Level 5 から、ドライバーの音声を処理できるという面白い API を利用できるようになりました。
  • 従来の Android アプリと同じく、音声パーミッションが必要です。権限のチェック及び付与を求めるダイアログ実装は簡単です。
  • ファイルに保存するコードは自力で書く必要があります。

ここにサンプルコードも作ってみたのでよかったら見てみてください。

github.com

Android Car App Library 1.3.0 が RC になりました

fix が主で特に API の追加はありませんが、1.3.0-beta が 1.3.0-rc になりました。
developer.android.com

alpha で入った API になりますが、1.3.0 系ではクルマのマイクを使って音声入力可能なCarAudioRecord が面白ポイントです。

developer.android.com

Mazda3 のスローパンクチャー対処その3

まさかの結果でした。

前々回、ディーラに「パンクは見当たりません」と返された Mazda3。
funnelbit.hatenablog.com


しかし空気は抜けていく。その後自分でバルブコアも変えてみたのですが
funnelbit.hatenablog.com


やはり空気は抜けていく結果に。これは相当厄介なスローパンクチャーになっていると思っていたのですが....

近所のタイヤ屋に持って行くと一瞬で原因が判明。

思い切り釘が刺さっていました 😇

しかもトレッドのど真ん中に。


プロがこれ見逃すってあるのか...?いくらなんでも技術なさすぎでは....


ディーラを信じてトレッド部見なかった自分が間抜けでした。信用するんじゃなかった....


トレッド部分なのでパンク修理可能。数分で修理されました。


最近育児で忙しかったので、メンテはアウトソーシングということでディーラーに投げる気持ちだったのですが、今回の件もあって考えが変わりつつあります。


そもそも自分が買ったディーラはミスがめちゃくちゃ多くて、来店するたびになんかミスされるという状況でした。オイル交換してないのにオイル計リセットされる、ガラスコーティング依頼していたのにせずに車を渡される、配線修理したら内装パネルが開きっぱなし、メンテパック契約後、必要書類が揃ってなくて再び来店して渡す、など...


良いディーラもあると思いますが、自分のディーラは大外れということで。延長保証入ってしまっているし、メンテパックも1年残っているのでまだ通いはしますが、自分でできる部分は自分でやろうという気分になりました。


そもそも車は自分でちゃんと点検する義務があるし、バイクの頃はメンテもほぼ全部自分でやっていたので、その頃のマインドに戻ろう、というところですね。

Watch Face Studio でウォッチフェイスを作ろう

Wear OS でウォッチフェイスを作る場合、コードを一切書くことなく、GUI エディタで秒針とか画像とかをドラッグして動かすだけで作成する Watch Face Studio というツールがあります。

developer.android.com

ウォッチフェイススタジオは↑からインストールできます。

最近発売した Pixel Watch を使って遊んでみましょう。
store.google.com

時刻表示の実装

例えば時針を追加する場合、メニューから時針を選ぶと画像選択画面になります。

エディタの画像フォルダにあらかじめ用意された時針があります。png 画像です。ここから選んでもいいし、自前で時針の動きをする画像を用意することもできます。

実機で動かす

実機をデバッグモードにします。設定 -> システム -> デバイス情報 -> バージョン とたどり、ビルド番号を連打したら開発者モードになれます。
その後、設定 -> 開発者向けオプション -> Wi-Fi 経由でデバッグ を On にしておきます。ドキュメントを見るとここで ip アドレスが表示されるとのことなのですが出てこないので、設定 -> 接続 -> Wifi から接続済みのアクセスポイントをタップして ip を表示して覚えておきます。

Run on Device を押すとビルドされ、接続先デバイス画面が出てきます。

まだ Pixel Watch は出てないと思うので接続します。+ をクリックして

ip を入れるダイアログが出てくるのでここでさっき覚えた ip を入れますと、時計にデバッグを許可するかどうか通知がくるので許可します。デバイス一覧に時計デバイスが表示され、一覧から時計デバイスを選択すると実機でウォッチフェイスが起動します。

アンビエントモード対応

Wear OS の文字盤は、しばらくすると画面が暗くなって省電力モードに移行します。Watch Face Studio ではこのモードに入った時に、必要ないものを簡単に非表示にできます。
例えば省電力モードでは秒針を動かすのは無駄なので消したい場合は、ALWAYS-ON タブを選んで、ここの目のマーク(change visibility)ボタンを押せば消すことができます。

ジャイロで遊ぼう

ジャイロが面白いです。手首の傾きを検知して画像を動かす、といったことができます。
画像を貼り付けて、PROPERTIES の GYRO の Apply Gyro をオンにして、適当に値を入れます。

RUN ペインを開くと、時計のセンサなどの値をエミュレートできます。ジャイロはぐりぐり回して遊べます。


バイスで動かしてみました。アンダーテールの犬がコロコロしています。

なお省電力モードではジャイロは無効になってます。

感想

Android のスマートウォッチが出た当時に一度ウォッチフェイスを作ろうとして、めんどくさすぎて諦めた経緯がありました。ですがこれだと一瞬で誰でも作れて嬉しいです。WatchOS 向けに作成するとストアに公開できないという制約にぶち当たりますが、Wear OS は公開できるのも嬉しいですね。ただジャイロのウォッチフェイス作るとなんだか時計の発熱と消費電力がきつい気がします。

Mazda3 のスローパンクチャー対処その2

前回、ディーラーから原因不明と返されたスローパンクチャー。
funnelbit.hatenablog.com

バルブコアを少し締めてみて250kPs空気を入れて1週間程度様子見したところ、50kPs ぐらい抜けていました。どう考えても異常です。

そのため次はバルブコアを交換してみることにしました。

まず必要なのはジャッキアップです。交換するタイヤの空気圧は0になるので車体を支えてあげる必要があります。


ここがジャッキアップポイントです。ジャッキは車載ジャッキを利用しました。使いやすくはないですが、まあこれくらいの作業であれば問題ないです。


裏から見たらこんな感じ。


ジャッキのレバーは2つのレバーを組み合わせる形式です。この状態で時計回りに回せばジャッキが上がります。

ジャッキ周りの説明は説明書に詳しく書いてあります。
https://www2.mazda.co.jp/carlife/owner/manual/mazda3/bp/ebmi/contents/18020300.html


ジャッキで車体をちょっとだけあげます。バルブコアを交換するタイヤが地面と触れない程度に上がればOK。



ちゃんと上がっていればタイヤを触ると動かせます。


タイヤが上がっているのを確認したら、虫まわしをバルブに押し付けて空気を抜いておきます。これをしないでバルブコアを取り外すと、200kPsの空気圧により、空気銃の要領でバルブコアが飛び出してきます。50kPs ぐらいまで抜いていればもう飛んでこないと思うので取り外します。


今回取り付けるバルブコアはエーモンのバルブコアです。

これを取り付けたら安物の空気入れでシュコシュコ空気を入れます。3分ぐらいで 250kPs まで入れることができると思います。

エアゲージで入っているのを確認したら、20分ぐらいジャッキアップしたままで放置します。交換した結果、不良があり急激に抜けてしまう可能性がないとは言えないからです。それでも空気が抜けていなければジャッキから下ろします。


これでまた1週間様子見ですね。取り外したバルブコア、よくみると軸が歪んでいるのでこれが原因だったら良いなあと思ってます。