20201216のAndroidに関する記事は5件です。

Flutterで辞書APIを使ってみた

はじめに

今までAndroidのアプリをJavaで作っていたんですが、新しいことをやりたくなったのでFlutterでアプリを作ってみました。

デモアプリを真似するだけでは面白くないので、Oxford Dictionaries APIを用いて簡単な辞書機能を搭載したアプリにしました。

Flutterはレイアウトも、dartで書かないといけない(これがいいところなのかもしれない)ので、なかなか思ったように表示されなくて難しかったです。

完成したアプリはこんな感じです。
完成したアプリ

Oxford Dictionaries API

今回使用した、英英辞書はOxford Dictionaries APIです。
https://developer.oxforddictionaries.com/

このAPIの特徴はプロトタイプ版なら月1,000リクエストまで無料で使えるところです。

辞書APIの使い方

App IDとApp Keyを取得

  1. サイトにアクセスして、GET YOUR API KEYを選択
    11.png

  2. PROTOTYPEを選択(アクセス数が大きい場合は有料プランを選択してください)
    12.png

  3. 必要事項を入力(そこまで詳しく入力しないでも大丈夫でした)
    13.png

  4. 入力したメールアドレスに認証用リンクが届くのでそこにアクセス
    (Gmailによってプロモーションに分類されていて、このメールを見つけるのに20分くらいかかった)

  5. サイトにもどってサインインする

  6. サインインすると、GET YOUR API KEYと書いてあった部分が、CREDENTIALSになっているので、そこを選択。
    14.png

  7. ~~~'s Appと書かれている部分を選択
    15.png

  8. Application IDApplication Keysが取得できる
    16.png

プログラム部分

アプリでHTTP通信をできるようにします。
まずは、pubspec.yamlに以下を追記します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  http: ^0.12.2 # 追記

Androidの場合はAndroidManifest.xmlに以下を追記します。

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="io.github.labsprout.english_cards">

    <!-- 以下の一行を追加 -->
    <uses-permission android:name="android.permission.INTERNET"/>

メインの部分はこんな感じになります。
(ちなみに、PythonJava用のプログラムはOxford Dictionaries APIのページにサンプルがあります)

OxfordDictionary.dart
import 'package:http/http.dart' as http;

Future<String> getRequest(String head) async {
  head = head.toLowerCase();

  final _language = "en-us";  // 1
  final _appId = "<your app id>";  // 2
  final _appKey = "<your app key>";  // 3

  final _link = "https://od-api.oxforddictionaries.com:443/api/v2/entries/" +
      _language + "/" + head +
      "?fields=definitions%2Cexamples%2Cpronunciations&strictMatch=false";  // 4

  try {
    final response = await http.get(_link,  // 5
        headers: {
          "Accept": "application/json",
          "app_id": _appId,
          "app_key": _appKey
        });

    return response.body.toString();
  } catch (e, stackTrace) {
    return e + " " + stackTrace;  // 6
  }
}

関数名のあとのasyncは非同期処理という意味です。それに伴って、返り値はStringではなくFuture<String>になってます。
(将来Stringを返すよって意味だと思います)

  1. 言語を選択(en-usはアメリカ英語という意味)
  2. 自分のAppIDに変更
  3. 自分のAppKeyに変更
  4. 通信先のURL(fielsはどんな情報が必要かを指定する部分です。指定しなければ全部の情報を得られます)
  5. _linkで指定したURLと通信(今回通信するためには、app_idapp_keyを指定する必要があるので、headersで指定しています)
  6. エラーだったときは、stackTraceを返す

メインのプログラムからこの関数を呼び出します。

main.dart
FutureBuilder(  // 1
  future: getRequest(head),
  builder: (BuildContext context,
      AsyncSnapshot<String> snapshot) {
    // 2
  }
)
  1. getRequest(head)で得られる値はFuture<String>なので、FutureBuilderを使って処理
  2. ここで受け取った値を処理
snapshot.data  // 関数から得られた値
snapshot.connectionState  // 接続状況

得られる値はjsonなので扱いやすいように(?)独自のクラスに変換しました。

変換先のクラスはこんな感じにしました。

WordData.dart
class Word {
  String head;
  String json;

  Word(this.head, this.json);
}

class WordData {
  String head;
  List<Entry> entries;

  WordData(this.head, this.entries);
}

class Entry {
  String audioURL;
  String ipa;
  List<Sense> senses;
  String category;

  Entry(this.audioURL, this.ipa, this.senses, this.category);
}

class Sense {
  String def;
  String example;

  Sense(this.def, this.example);
}

jsonから独自クラスへの変換をする関数はこんな感じ。

WordData.dart
import 'dart:convert';

WordData convertToWordData(Word word) {
  List<Entry> entries = [];
  Map map = jsonDecode(word.json);

  List jsonEntries = map["results"][0]["lexicalEntries"];

  jsonEntries.forEach((element) {
    String audioFile = element["entries"][0]["pronunciations"][1]["audioFile"];
    String ipa = element["entries"][0]["pronunciations"][1]["phoneticSpelling"];

    List jsonSenses = element["entries"][0]["senses"];
    List<Sense> senses = [];

    jsonSenses.forEach((element1) {
      String def = element1["definitions"][0];

      print(def);
      print(element1);
      String example = "";
      if (element1.containsKey("examples")) {
        print("yes");
        example = element1["examples"][0]["text"];
      }
      print("all");

      Sense sense = new Sense(def, example);

      senses.add(sense);
    });

    String category = element["lexicalCategory"]["text"];

    Entry entry = new Entry(audioFile, ipa, senses, category);

    entries.add(entry);
  });

  return new WordData(word.head, entries);
}

jsonDecode()は自動でmapだったりlistだったりに変換してくれるので扱いやすかったです。

あとは、画面に表示するwidgetを作ります。

main.dart
Word word = new Word(head, snapshot.data);
WordData wordData = convertToWordData(word);  // 1

List<Widget> widgets = [];  // 2
wordData.entries.forEach((entry) {  // 3
  widgets.add(new Divider());
  widgets.add(new Text(
    head + ' (' + entry.category + ') [' + entry.ipa + ']',
    style: TextStyle(
      fontSize: 18.0,
      color: Colors.blue
    ),
  ));
  entry.senses.forEach((sense) {
    widgets.add(new Text(
      sense.def,
      style: TextStyle(
        fontSize: 16.0
      )
    ));
    if (sense.example != "") {
      widgets.add(new Text(
        ' ex.) ' + sense.example,
        style: TextStyle(
          fontSize: 16.0,
          color: Colors.grey[700]
        ),
      ));
    }
  });
});

// 省略

return Column(  // 4
  children: widgets,
  crossAxisAlignment: CrossAxisAlignment.start,
);
  1. 独自のクラスに変換
  2. 画面に表示するwidgetをリスト(widgets)にして保管
  3. wordDataに入っている情報をforEachで取り出す
  4. widgetsに入っているwidgetをすべて縦にならべる

ファイルの読み書き

ファイルの読み書きは、Javaに比べて簡単にできました。

まず、アプリ内のパスを取得できるようにするためにpubspec.yamlに以下を追記してpathpath_providerを読み込みます。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  path_provider: any # 追記 

保存

saveWordText(String word, String json) async {
  final directory = await getApplicationDocumentsDirectory(); // 1
  final file = File(directory.path + '/word_' + word + '.txt'); // 2

  file.writeAsString(json); // 3
}
  1. ファイルを保存できるディレクトリを取得
  2. 適当なパスを指定してファイルを取得
  3. 書き込み処理

読み込み

Future<String> getWordText(String word) async {
  final directory = await getApplicationDocumentsDirectory();

  File file = new File(directory.path + '/word_' + word + '.txt');
  if (await file.exists()) {
    return file.readAsString(); // (*)
  } else {
    return "";
  }
}

ファイルが存在するかチェックした後、(*)の部分で読み込みを行ってます。

削除

deleteWordText(String word) async {
  final directory = await getApplicationDocumentsDirectory();
  File file = new File(directory.path + '/word_' + word + '.txt');

  if (file.existsSync()) {
    file.delete();
  }
}

おわりに

プログラム全体はGithubに上げてあります。
https://github.com/LabSprout/english_cards

記事書いてて気がついたんですけど、型推論使ってたり使ってなかたっりぐちゃぐちゃですね。ごめんなさい。
(これって使い分けあったりするんだろうか)

Flutterで自分のアプリを作るのも、Qiitaに記事を投稿するのも初めてなので微妙なところがあったら、ぜひ教えて下さい。

最後まで読んで下さりありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android】LinearLayoutの使い方

プログラミング勉強日記

2020年12月16日
LinearLayoutを始めて使ったので、LinearLayoutとは何か・使い方を簡単にまとめる。

LinearLayoutとは

 LinearLayout(読み方:「リニアレイアウト」)は、単純なレイアウトの1つで、子要素を縦・横の一列に並べるレイアウトである。android:orientation属性にvertical, horizontalを指定することでそれぞれ縦、横に並べることができる。

LinearLayoutを使って子要素を縦一列に並べる方法

 app/src/main/res/layout/activity_main.xmlでレイアウトエディタのデザインから配置することができる。

  1. パレット内にあるLayoutを選択
  2. LinearLayout(Vertical)を選択
  3. プレビュー画面にドラッグして配置する

image.png

4. 同様にして配置したいレイアウトをプレビュー画面にドラッグする

image.png

※この時点ではLinearLayoutにConstraintLayoutの制約がないためエラーが出ている。LinearLayoutにtools:ignore="MissingConstraints"を記述することでエラーを消した。(ConstraintLayoutの制約については昨日の記事で挙げている)
 

activity_main.xmlのコード
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="409dp"
        android:layout_height="729dp"
        android:orientation="vertical"
        tools:layout_editor_absoluteX="1dp"
        tools:layout_editor_absoluteY="1dp"
        tools:ignore="MissingConstraints">

        <Button
            android:id="@+id/button4"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Button" />

        <Button
            android:id="@+id/button5"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Button" />

        <Button
            android:id="@+id/button6"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Button" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

最終的なデザインの画面↓
image.png

要素を横に並べたい場合は、LinearLayout(Horizontal)にする。やることは縦に並べるときと同じ。

参考文献

縦または横一列に要素を並べるリニアレイアウト (LinearLayout)
Androidアプリ開発のLinearLayoutの使い方【初心者向け】

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitHub Actions で Android プロジェクトのプルリクのチェックを行う(ゆめみ社の事例)

これは ゆめみ Advent Calendar 2020 の9日目の記事です。今回は、ゆめみの Android プロジェクトでよく導入している、GitHub Actions でのプルリクチェックのワークフローを紹介したいと思います :muscle:

ゆめみの Android グループは、約20以上の多種多様なお客様の Android プロジェクトに携わっています。一部例外はありますが、多くのプロジェクトの CI は、Bitrise と GitHub Actions を併用して構築しています。以前はプルリクのチェックは Bitrise を利用して実施していましたが、最近は GitHub Actions に乗り換えつつあります。

プルリクのチェックでは ktlint、Android Lint、Local unit test を実行し、Danger を利用してチェック結果をコメントさせています。これを実現するワークフローは次の通りです。(この記事の為にコメントを多めにいれています。)

.github/workflows/check-pull-request.yml
name: Check pull request

on: pull_request

env:
  # 実行する Gradle コマンド(プロジェクトによって調整してください。)
  GRADLE_KTLINT_TASK: 'ktlint'
  GRADLE_ANDROID_LINT_TASK: 'lintDevelopDebug'
  GRADLE_UNIT_TEST_TASK: 'testDevelopDebugUnitTest'

jobs:
  check:
    name: Check pull request
    runs-on: ubuntu-18.04 # Java や Ruby を利用するので念の為、環境を固定
    steps:
      - name: Check out
        uses: actions/checkout@v2
        with:
          fetch-depth: 0 # 0 を指定しないと Danger でエラーとなる。代わりに ref: ${{ github.event.pull_request.head.sha }} としてもいいかもしれません
      - name: Set up JDK
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Restore gradle cache # Gradle のキャッシュをリストア
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }}
      - name: Set up Ruby # gem を利用するので Ruby をセットアップ
        uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - name: Get gem info
        env: # Danger で利用する gem をここで列挙
          PACKAGES: danger:6.2.0 danger-checkstyle_format:0.1.1 danger-android_lint:0.0.8 danger-junit:1.0.0
        id: gem-info
        run: |
            echo "::set-output name=dir::$(gem environment gemdir)" # キャッシュするgemのディレクトリ
            echo "::set-output name=packages::$PACKAGES" # install 用の文字列
            echo "::set-output name=key::$(echo $PACKAGES | tr ' ' '-')" # キャッシュのキー文字列
      - name: Restore gem cache # gem のキャッシュをリストア
        uses: actions/cache@v2
        with:
          path: ${{ steps.gem-info.outputs.dir }}
          key: ${{ runner.os }}-gem-${{ steps.gem-info.outputs.key }}
      - name: Run ktlint
        run: ./gradlew $GRADLE_KTLINT_TASK
      - name: Run Android Lint
        run: ./gradlew $GRADLE_ANDROID_LINT_TASK
      - name: Run Unit Test
        run: ./gradlew $GRADLE_UNIT_TEST_TASK
      - name: Set up and run Danger
        if: cancelled() != true # 中断されない限り、エラーでも実行
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 標準で利用できるトークンを利用
          JOB_STATUS: ${{ job.status }} # jobのステータスを Danger へ受け渡す
        run: |
          gem install ${{ steps.gem-info.outputs.packages }}
          danger --dangerfile='.github/workflows/check-pull-request.danger' --remove-previous-comments --fail-on-errors=true

このワークフローでは、Danger のプラグインとして danger-checkstyle_formatdanger-android_lintdanger-junit を利用しています。

gem でインストールした Danger をコマンドラインで起動しています。コマンドラインのオプションについては GitHub の runner.rb ファイルを参照ください。(ドキュメントは見つけられませんでした。)

Danger 用のスクリプトファイルは次のものを用意し、上で説明したのワークフローの yaml ファイルと同じディレクトリに格納します。(こちらも、この記事の為にコメントを多めにいれています。)

.github/workflows/check-pull-request.danger
# GitHub Actions の job のステータスを受け取る
job_status = ENV['JOB_STATUS']

# 追加・変更していないコードはコメント対象外とするか
github.dismiss_out_of_range_messages({
  error: false, # エラーは追加・変更していないコードでもコメント
  warning: true,
  message: true,
  markdown: true
})

# ktlint の結果ファイルの解析とコメント
Dir.glob("**/build/reports/ktlint-results.xml").each { |report|
  checkstyle_format.base_path = Dir.pwd
  checkstyle_format.report report.to_s
}

# Android Lint の結果ファイルの解析とコメント
Dir.glob("**/build/reports/lint-results*.xml").each { |report|
  android_lint.skip_gradle_task = true # 既にある結果ファイルを利用する
  android_lint.report_file = report.to_s
  android_lint.filtering = false # エラーは追加・変更したファイルでなくてもコメント
  android_lint.lint(inline_mode: true) # コードにインラインでコメントする
}

# 最終結果でレポートするワーニング数は Android Lint と ktlint のみの合計としたいのでここで変数に保存
lint_warning_count = status_report[:warnings].count

# Local unit test の結果ファイルの解析とコメント
Dir.glob("**/build/test-results/*/*.xml").each { |report|
  junit.parse report
  junit.show_skipped_tests = true # スキップしたテストをワーニングとする(状況により適宜変更)
  junit.report
}

# プルリクの body が空の場合はエラー
fail 'Write at least one line in the description of PR.' if github.pr_body.length < 1

# プルリクが大きい場合はワーニング
warn 'Changes have exceeded 500 lines. Divide if possible.' if git.lines_of_code > 500

# 追加で独自のチェックをする場合はこのあたりで実施する
# ...

# Danger でエラーがある場合は既に何かしらコメントされているのでここで終了
return unless status_report[:errors].empty?

# GitHub Actions のワークフローのどこかでエラーがあった場合はその旨をコメントして終了
return markdown ':heavy_exclamation_mark:Pull request check failed.' if job_status != 'success'

# 成功時のコメント(もし不要な場合は省いてもいいと思います)
comment = ':heavy_check_mark:Pull request check passed.'
if lint_warning_count == 0
  markdown comment
else
  # ktlint と Android Lint のワーニング数の合計をレポート
  markdown comment + " (But **#{lint_warning_count}** warnings reported by Android Lint and ktlint.)"
end

仮に Android プロジェクトのコンパイルが通らない場合は Android Lint あたりでエラーになるので、一応そこで気づくことができます :eyes:(ビルドできるかのステップを追加してチェックしてもよいのですが、そうすると時間がかかってしまうので追加していません。)

この2ファイルを含むブランチでプルリクエストを作成してみてください。そうするとプルリク上でワークフローが実行中の状態になります。

ワークフローの実行状況や結果は、上図の Details リンクから確認することができます。どこでエラーになったかもここで確認することができます。

Danger のコメント例をいくつか紹介します。ワーニング(:warning: マークのコメント)に関しては、エラーと違い警告だけなので、いくらワーニングがあってもプルリクのチェック結果としては成功ステータスとなります。

ktlint または Android Lint のワーニング:

対象のコードにインラインでコメントされます。(ボットがレビューしているようで楽しいです :robot:

Android Lint のエラー:

Local unit test のエラー:

ワークフロー上で何かしらのエラーがある場合:

この場合はワークフローの実行結果画面からエラーの原因を探すことになります。(が、コンパイルエラーの場合が殆どです。)

エラーが無い場合:

成功の旨がコメントされます。

このように、GitHub Actions を利用することで、特別な CI サービスの契約なしに簡単に CI のワークフローを構築することができます。ワークフローがファイルとしてリポジトリで管理でき、実行状況や結果の確認も GitHub 内で完結しているのもいいですね :smiley:

そのほかの設定

Gradle と gem のキャッシュの作成

紹介したワークフローでは actions/cache で Gradle と gem のキャッシュを利用するようにしていますが、キャッシュはブランチに紐づいています。プルリクで新しいブランチを作成して push しても、その新しいブランチには当然、キャッシュが存在しません。コードの追加 push 時にはキャッシュが適用されはするのですが、キャッシュを十分に利用できておらず微妙な感じです。

ただ actions/cache は、プルリクのブランチにキャッシュが無ければマージ先のブランチのキャッシュを、マージ先に無ければデフォルトブランチのキャッシュを探索し利用する仕様になっています。よって、それらのブランチに更新があったタイミングでキャッシュを作成するワークフローを用意しておけば、プルリクの新規作成のタイミングでもキャッシュを利用できる状態にすることができます :thumbsup:

このワークフローは次のとおりです。(内容は先程のワークフローとだいたい同じなので細かい説明は省きます。)

.github/workflows/generate-cache.yml
name: Generate cache

on:
  push:
    branches: # デフォルトブランチや主たるマージ先ブランチを指定
      - master
      - develop*

jobs:
  create:
    name: Generate cache
    runs-on: ubuntu-18.04
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: Set up JDK
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Restore gradle cache
        id: gradle-cache
        uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }}
      - name: Download dependencies
        if: steps.gradle-cache.outputs.cache-hit != 'true' # キャッシュが無い場合だけ実行
        run: ./gradlew androidDependencies
      - name: Set up Ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - name: Get gem info
        env:
          PACKAGES: danger:6.2.0 danger-checkstyle_format:0.1.1 danger-android_lint:0.0.8 danger-junit:1.0.0
        id: gem-info
        run: |
          echo "::set-output name=dir::$(gem environment gemdir)"
          echo "::set-output name=packages::$PACKAGES"
          echo "::set-output name=key::$(echo $PACKAGES | tr ' ' '-')"
      - name: Restore gem cache
        id: gem-cache
        uses: actions/cache@v2
        with:
          path: ${{ steps.gem-info.outputs.dir }}
          key: ${{ runner.os }}-gem-${{ steps.gem-info.outputs.key }}
      - name: Set up Danger
        if: steps.gem-cache.outputs.cache-hit != 'true' # キャッシュが無い場合だけ実行
        run: |
          gem install ${{ steps.gem-info.outputs.packages }}

ktlint の導入

基本的には ktlint の README に導入方法が書かれていますが、

  • マルチモジュールへの対応
  • checkstyle 形式のレポートの追加(Danger 用)
  • ktlint からの指摘があっても CI(GitHub Actions)上で異常終了させない

というのを考慮して、プロジェクトルートの build.gradle:app 配下のではなく)に次のように記載します。

build.gradle
// ...

subprojects { // 配下のモジュール全てに適用される

    configurations { ktlint }

    dependencies { ktlint "com.pinterest:ktlint:0.39.0" }

    task ktlint(type: JavaExec, group: "verification") {
        description = "Check Kotlin code style."
        main = "com.pinterest.ktlint.Main"
        classpath = configurations.ktlint
        args "--android", "--color", "--reporter=plain", "--reporter=checkstyle,output=${buildDir}/reports/ktlint-results.xml", "src/**/*.kt"
        ignoreExitValue true
    }

    task ktlintFormat(type: JavaExec, group: "formatting") {
        description = "Fix Kotlin code style deviations."
        main = "com.pinterest.ktlint.Main"
        classpath = configurations.ktlint
        args "-F", "--android", "src/**/*.kt"
        ignoreExitValue true
    }

    afterEvaluate {
        check.dependsOn ktlint
    }
}

ktlint から指摘があったらワークフローを失敗ステータスにする厳しい運用にする場合は ignoreExitValue true の記載は削除してください。

もしモジュールによって動作を変えたい場合は、it.name でモジュール名が取得できるので、判定処理をいれるとよいでしょう。例えばライブラリ導入用のモジュールがあったりすると、Gradle の check タスクがなくエラーになったりするので、次のような判定で回避します。

afterEvaluate {
    if (it.name != "awesome_module") {
        check.dependsOn ktlint
    }
}

次に、プロジェクトルートに .editorconfig ファイルを用意して、ktlint の追加の設定をします。

.editorconfig
[*]
insert_final_newline = true

[*.{kt, kts}]
max_line_length = 128
disabled_rules = import-ordering

insert_final_newline を有効にすると、ファイルの末尾が改行文字であるかチェックされるようになります。Android Studio のコードフォーマッタもこの設定を参照しているので、ファイルの末尾が改行文字ではない場合は自動的に補完されるようになります。(この例では、Java のファイルなど、Kotlin 以外のファイルでも自動的に補完したいので [*] で全ファイルを対象にしています。)

max_line_length は、コード一行の最大文字数の設定です。デフォルトだと 100 なのですが、弊社では 128 にしています。このあたりはプロジェクトによって調整してください。

disabled_rules には、無効にする ktlint のルールを指定します。この例では import-ordering(コードの import 文の並び順のチェック)を無効にしています。現状、Android Studio のコードフォーマッタで並び替えた順序が ktlint の指摘の対象になってしまうからです。このあたり、最新バージョンの ktlint を利用(私が試したのは 0.39.0 まで)したり、フォーマッタの設定を調整したり、.editorconfig ファイルを調整したりすれば回避できるのかもしれません。(私は何をしても上手くいきませんでした..)

また、Android Studio のコードフォーマッタも、なるべく ktlint の指摘が少なくなるように設定されてあるものを利用する方がよいでしょう。おすすめの設定は ktlint の README に書いてあります。

プルリクのマージのブロック

リポジトリの Branch protection rules の設定で、今回のワークフローのジョブ(Check pull request)のステータスをチェックするようにします。ワークフローの実行中やエラー時はマージボタンを非活性にすることができるので、中途半端な状態でマージされるのを防げます。

Danger(この画面での表示は danger/danger)もステータスを更新しますが、Danger でエラーの場合は今回のワークフローもエラーになるので、ここでの Danger のステータスのチェックは特に不要です。

おわりに

いかがでしたでしょうか。今回は、ゆめみの Android プロジェクトでよく導入しているプルリクチェックのワークフローを紹介させて頂きました。他にも紹介したいワークフローはまだまだあるのですが、それはまた別の機会に紹介させて頂きます :alien:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android11でメディアコントロールが出なかった。

結論

きちんとMediaSessionにメタ情報を設定することでメディアコントロールが出ましたので、きちんとメタ情報が設定されているか確認をしましょう。

mediaMetadata = new MediaMetadataCompat.Builder();
    .putString(MediaMetadata.METADATA_KEY_TITLE, title) 
    .putString(MediaMetadata.METADATA_KEY_ARTIST, artistName)
    .putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, imageUri)) 
    .putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
    .build();
mediaSession.setMetadata(mediaMetadata)

またputLong(MediaMetadataCompat.METADATA_KEY_DURATION, -1);とすることで、シークバーを無効化することができますが、シークバー自体を消すことはできないようです。

経緯

AndroidにはMediaSessionという、外部デバイスなどからメディアが操作されたことをうまく制御する仕組みがあるのですが、
その中で、再生情報はMediaMetadataで管理するということになっています。
(詳しい記事はたくさんあるのでここでは詳しい解説は省きます。)

しかし、MediaMetadataとは別にデータを管理していたため設定せずに完結していたこと。
メタ情報を設定せずともAndroid11以前はプレイヤーの表示・操作ができていたため、メタ情報の設定がされていないことに気付きにくかった。

以上のことからメタ情報の設定がされていなかったことに現在まで気づくことができていませんでした。

最低限必要なメタ情報とメディアコントロールの表示の調査

Notificationのタイトル情報とMediaMetadataの関係を調査したところ以下のことがわかりました。

  • MediaMetadataに対応するデータがある場合メタ情報が表示に使用される。
  • 対応するメタ情報がないときはNotificationへ設定した値が表示される。
  • MediaMetadataの中身が空っぽでも、Notificationに設定した情報が表示される。

インスタンスを生成して渡すだけでNotificationの情報から表示がされるようですが、しっかりと設定しましょう。

あとがき

本当はAndroid11から追加されたバブルに触れてみたのですが、ギリギリまで試行錯誤して、
一部端末でバブルの機能が動かないという報告を発見して断念しました。
そちらについては確認次第また別の機会に記事にしようと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

M5Stack遠隔押印装置

はじめに

リモートワークが増えている中で押印してもらうためだけに出社することがあります。世間では判子をなくそうという風潮です。しかし、判子は日本の文化だと思います。

  • 自分の判子をもらったときの嬉しさ
  • 上司になって押印したときの責任感

時代は電子承認の流れですが、1クリックで電子書類に印影が入るだけじゃダメなんです!
物理的に判子を押す感覚が欲しいんです!

ということで、物理的に判子を押して、無駄にクラウドを使用して電子承認する仕組みを作りました。

デモ動画は「承認野郎Aチーム」(ネタが古い...)
承認野郎Aチーム

開発環境

  • M5Stack
  • 感圧センサー(円形・大)
  • 判子(今回は鈴木さん限定)
  • 押印用スタンプ台
  • 承認欄(この裏に感圧センサーを仕込む)
  • Firebase Realtime Database
  • Android端末(Nexus7)

parts_list.jpg

構成図と押印の仕組み

構成図

hanko_develop.png

承認依頼と押印フロー

  • Android端末がRealtime Databaseから依頼を監視します。
  • 依頼があると音声案内されます。
  • 承認する場合は、承認蘭(物理)で押印します。
    ここで押印の強さと時間を計測して、押印の濃さに利用します。
  • 押印が確定したら、Realtime Databaseへ押印情報(結果・強さ・時間など)を書き込みます。
  • Android端末が承認情報を監視しているので、押印情報から画面へ押印します。
    押印の強さと時間により押印の濃さが変わります。

押印検出

  • 感圧センサーをADして開始閾値(0.5V)超えたら押印開始と判断します。
  • 完了閾値(0.2V)未満なら押印完了と判断します。
  • 押印監視から押印完了までを押印時間(msec)とします。
  • 押印の強さの判定用に最大電圧を保存しておきます。

voltage_measure.png

Realtime Databaseデータ構造

Realtime Databaseのデータ構造をAndroidのコードから¥説明します。

data class ApprovalStatus(
    var status : Int = 0,       // 押印ステータス(0:初期状態 1:押印開始 2:押印完了)
    var result : Int = 0,       // 承認結果(0:否認 1:承認)
    var type : Int = 0,         // 押印タイプ(0:真っ直ぐ 1:お辞儀 2:逆さま)
    var power : Float = 0.0f,   // 押印力(V)
    var presstime : Int = 0     // 押印時間(msec)
)
  • 押印ステータスは、承認依頼がきたときに0、M5Stackで押印完了したときに2です。(1は未使用)
  • 押印タイプは、現在M5Stackからは0固定です。(Androidアプリ側で制御)
  • 押印力は、最大電圧値を設定します。
  • 押印時間は、押印開始を検出してから押印完了までの時間を設定します。

ソースコード

M5StackとAndroid(承認取得と表示)のソースコードを説明します。

M5Stack

定義など

  • AD計測ピン番号、押印検出状態、Firebase接続情報を定義します。
#include <M5Stack.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>

// 定義関連
// AD計測ピン番号
#define ANALOG_READ_PIN 35

#define TH_PUSH_DETECT    (0.5)   // 押印検出閾値(V)
#define TH_PUSH_FINISH    (0.2)   // 押印終了閾値(V)

// 押印検出状態
enum StatusDetect {
  DETECT_INIT = 0,     // 初期状態
  DETECT_START = 1,    // 検出開始
  DETECT_FINISH = 2    // 検出終了
};

StatusDetect statusDetect = DETECT_INIT;  // 押印検出状態
unsigned long timeDetectStart = 0;        // 押印検出開始時間(msec)
unsigned long timeDetectEnd = 0;          // 押印検出終了時間(msec)
float peekVoltage = 0.0;                  // 最大電圧

// WiFiクライント(Firebase通信で使用)
WiFiClientSecure client;

// Firebase接続先
const char* firebase_host = "xxxx.firebaseio.com";
// Firebaseのシークレットキー
const char* firebase_secrets = "xxxx";
// Realtime Database保存パス
const char* path_hankoplus = "hankoplus/status";

// 押印検出情報初期化
void initDetect() {
  statusDetect = DETECT_INIT;
  timeDetectStart = 0;
  timeDetectEnd = 0;
  peekVoltage = 0.0;
}

初期化処理とメイン処理

void setup() {
  M5.begin();

  // スピーカーOFF
  M5.Speaker.write(0);

  // AD計測ピン設定
  pinMode(ANALOG_READ_PIN, ANALOG);

  // Wifi接続(省略)

  // 押印検出情報初期化
  initDetect();
}

void loop() {
  M5.update();

  // 押印計測処理
  measure();

  // 押印終了していればFirebaseへ保存
  if (statusDetect == DETECT_FINISH) {
    int statusPush = 2;  // 2:完了
    int result = 1;      // 1:成功
    int typePush = 0;    // 真っ直ぐ
    unsigned long presstime = timeDetectEnd - timeDetectStart;

    // 送信データ(JSON)生成
    String body = "";
    body += "{\n";
    body += "\"status\":" + String(statusPush) + ",\n";
    body += "\"type\":" + String(typePush) + ",\n";
    body += "\"result\":" + String(result) + ",\n";
    body += "\"power\":" + String(peekVoltage) + ",\n";
    body += "\"presstime\":" + String(presstime) + "\n";
    body += "}";

    // Firebaseへ保存
    postServerSentEvents(body);
  }
}

感圧センサーの押印検出処理

  • 感圧センサーからの電圧を計測して開始閾値超えを検出したら、押印を開始したと判断します。
  • 電圧が完了閾値未満を検出したら、押印が完了したと判断します。
  • 押印開始から押印完了までの時間(msec)と最大値(V)を記憶しておきます。(Firebaseへ保存時に使用)
// 押印計測処理
void measure() {
  int val = 0;
  float voltage = 0.0;
  float voltageTotal = 0.0;
  float voltageAvg = 0.0;
  int count = 100;

  // 電圧を100回計測して平均化
  for (int i = 0; i < count; i++) {
    val = analogRead(ANALOG_READ_PIN);
    voltage = (float)(val) * (3.3 / 4096.0);
    voltageTotal += voltage;
    delay(1);
  }
  voltageAvg = voltageTotal / (float)count;

  switch (statusDetect) {
    case DETECT_INIT:
      // 検出閾値を超えるまで待つ
      if (voltageAvg > TH_PUSH_DETECT) {
        statusDetect = DETECT_START;   // 押印検出状態へ遷移
        timeDetectStart = millis();    // 検出開始時間を保存
        peekVoltage = voltageAvg;      // 最大値を保存
      }
      break;
    case DETECT_START:
      // 最大値チェック
      if (voltageAvg > peekVoltage) {
        peekVoltage = voltageAvg;      // 最大値を保存
      }
      // 検出閾値を下回るまで待つ
      if (voltageAvg < TH_PUSH_FINISH) {
        statusDetect = DETECT_FINISH;  // 検出終了へ遷移
        timeDetectEnd = millis();      // 検出終了時間を保存
      }
      break;
    default:
      break;
  }

Android(承認取得と表示)

Androidは承認状態の監視と印影画像作成の処理のみ示します。

承認状態の監視

  • 承認状態(ApprovalStatus.status)が押印完了(2)になるのを監視します。
  • modestampが0のときは真っ直ぐな印影(社長は必ずこれ)、1のときはお辞儀した印影を表示します。
// 承認状態監視Listener
private val listenerApprovalStatus = object : ChildEventListener {
  override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {
    // hankoplus/statusの変更あり
    val value = snapshot.getValue(ApprovalStatus::class.java)
    value?.let {
      // 承認状態で分岐
      when (it.status) {
        0 -> {}  // 初期状態
        1 -> {}  // 押印判定開始
        2 -> {   // 押印完了(承認/否認いずれも)
          if (it.result == 1) {
            // 押印パワー取得(最大2.0Vとする)
            var power = it.power
            if (power > 2.0f) {
              power = 2.0f
            }
            // 押印時間取得
            val presstime = it.presstime

            // 押印濃度計算(割合 -> パワー=0.4 時間=0.6)
            val arph = (((power / 2.0f) * 0.4f + (presstime.toFloat() / 8000.0f) * 0.6f) * 256).toInt()
            var bmp: Bitmap? = null
            if (modeStamp == 0) {
              // 社長は必ず真っ直ぐ
              bmp = createApproval("鈴木", 0, arph)
            } else {
              bmp = createApproval("鈴木", typeStamp, arph)
            }
            setApproval(bmp)

            // 次の承認ように承認状態を初期化
            val updates = mutableMapOf<String, Any?>("status" to 0)
            refApprovalStatus?.child("status")?.updateChildren(updates)
          }
        }
      }
    }
  }

  override fun onCancelled(databaseError: DatabaseError) {}
  override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
  override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {}
  override fun onChildRemoved(snapshot: DataSnapshot) {}
}

印影画像の作成

  • 押印濃度を計算したアルファ値で印影画像を作成します。
    計算式は、押印した実験結果から独自に決定したものです。
  • Android端末(Nexus7用)と承認用紙の画面に合わせて作成してます。
// 承認画像の作成
private fun createApproval(name: String, type: Int, arph: Int): Bitmap {
  val width = 400
  val height = 400
  val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  val canvas = Canvas()
  canvas.setBitmap(bmp)

  val paintBackground = Paint()
  paintBackground.setColor(Color.WHITE)
  val rect = Rect(0, 0, width, height)
  canvas.drawRect(rect, paintBackground)

  val paintName = Paint()
  paintName.textSize = 170.0f
  paintName.isAntiAlias = true
  paintName.setColor(Color.argb(arph, 255, 0, 0))

  val paintFrame = Paint()
  paintFrame.setColor(Color.argb(arph, 255, 0, 0))
  paintFrame.style = Paint.Style.STROKE
  paintFrame.strokeWidth = 16.0f

  val cx = width / 2.0f
  val cy = height / 2.0f
  val radius = (width * 0.9f) / 2.0f
  canvas.drawCircle(cx, cy, radius , paintFrame)
  var str = name.get(0).toString()
  canvas.drawText(str, 115.0f, 175.0f, paintName)
  str = name.get(1).toString()
  canvas.drawText(str, 115.0f, 340.0f, paintName)

  val mat = Matrix()
  when (type) {
    0 -> {  // 真っ直ぐ
      mat.postRotate(0.0f, cx, cy)
    }
    1 -> {  // お辞儀
      mat.postRotate(-30.0f, cx, cy)
    }
    2 -> {  // 逆さま
      mat.postRotate(180.0f, cx, cy)
    }
  }
  val bmpMat = Bitmap.createBitmap(bmp, 0, 0, width, height, mat, true)
  val bmpStamp = Bitmap.createBitmap(bmpMat, (bmpMat.width / 2.0f - width / 2.0f).toInt(), (bmpMat.height / 2.0f - height / 2.0f).toInt(), width, height)
  return bmpStamp
}

参考サイト

Firebase Realtime Database のデータ保存、取得、ストリーミング受信実験( ESP32 , M5Stack )

おわりに

物理判子を押すというアナログな操作をM5Stackでデジタルに変換して、Firebaseでクラウドを使ってタブレットと連携までまるっとできるのは楽しいです。

開発当初は、押印方向(真っ直ぐ、お辞儀、逆さま)をM5Stackのボタンで選択していましたが、試験してもらったときに操作が面倒だったので、現在は承認書類が押印方向の情報を持っていて書類によりきまるようにしています。(デモ動画では書類の内容や情報は内部で固定ですが)
現在は鈴木さん限定なので、いろいろなお名前やオリジナル印影にも挑戦したいですね。

M5Stackはセンサー類も充実していますし、感圧センサーのような変化を電圧値として計測できれば、いろいろと応用ができるのが楽しいので、今後もネタかつ実用的なものを作っていきたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む