20201218のAndroidに関する記事は12件です。

webViewでandroidのデフォルトのエラーページが表示される/チラつく時の対処方法

注意

本記事はreact nativeの記事です。

はじめに

webViewでページを表示する時に何らかの読み込みエラーとかが出るとあまり印象が良くないので

      renderError={() => {
        return (
          <View>
            <Text>読み込みできませんでした</Text>
          </View>
        )
      }}

みたいな感じでエラー用の表示を作ると思うのですが、なんとAndroidだとrenderErrorを無視してデフォルトのエラーページがでーんと残ったりページを開く時や閉じる時にエラーページがチラつくといった現象が発生します。
色々と調べて対処したので、お困りの方は是非お試しください。

対応内容

  • ページを表示する時にディレイをかける
  • ディレイ中は読み込みインジケーターを表示させる
  • エラーになった場合はabout:blankのページを読み込む
state = {
  url: '',    // webViewで読み込む用のurl
  loading: true   // ディレイ用の変数
}


componentDidMount(){
    // 読み込むページのurlを格納する
    this.setState({ url: this.props.url })

    // androidの時、1秒のディレイをかける
    Platform.OS === 'android'
      ? setTimeout(() => {
          this.setState({ loading: false })
        }, 1000)
      : this.setState({ loading: false })

}

// blankページをセットする関数用意
setUrlBlank() {
  this.setState({ url: 'about:blank' }, () => {})
}

render() {
  return (
    {!this.state.loading ? (
      <WebView
        source={{ uri: this.state.url }}
        onError={e => {
          this.setUrlBlank()
        }}
      />
    ) : (
      <View style={styles.loadingIndicatorContainer}>
        <ActivityIndicator size="large" />
      </View>
  )
}

解説

まず、ページを読み込んだ時のチラつきを防ぐために、ページ表示前にディレイをかけます。
ディレイ中にインジケーターを表示させることで、ユーザーのストレスを軽減します
エラーになった時にデフォルトエラーページが出ないようにabout:blankのページをwebViewで読み込ませます。
ただこれだとエラー時に真っ白なページを表示するだけになってしまうので、別途エラー用のHTMLを用意して
読み込ませてあげるとなお良いと思います。

これで一旦は対応ができますが、個人的にはもっとベストプラクティスがありそうな気がしています。。

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

【ReactNative】実機ビルド、APK生成で起きたトラブルシューティング~Android編~

ReactNativeを頑張っているみなさんこんにちは。ブリューアスのwebフロントエンジニアのsuginokoです。
もう年末ですね。1年というのはあっという間です。

弊社ではじわじわとReactNativeの案件にも関わるようになってきておりワタシも初めての経験でアタフタしましたが、なんとか今年のゴールにたどり着けそうです。

今回は初めてのReactNativeということで沢山のトラブルに出くわしたので、その対応トラブルシューティングを書いていこうと思います。アプリ開発に携わったことのない同じようなフロントエンジニアさんに届きますように~

前置き

今回1からプロジェクトを立ち上げたわけではなく、既に実装済みのReactNativeのプロジェクトを頂きました。
実装内容としましても少し古い書き方になっている箇所も多々ありましたので、そちらをリファクタを行ったり、見た目部分を修正するなど、実装自体はそこまで重くなかった対応になります。
辛かったのは最初(環境構築)と最後(実機ビルド、APK生成)です。

環境

  • react: 16.9.0
  • react-native: 0.61.5
  • react-native-agora: 2.9.1-alpha.2
  • react-native-firebase: 5.6.0

※パッケージはおおまかに使用したものだけ記載
基本的にはwindowsで実装してますが、expoは使用しておらず、ios開発は別途Macを用意して確認しつつの実装を行ってました。
yarnも使ってます。

お客様の環境が開発用と本番用としか分かれてなかったため、buildTypeとしては

  • Debug(開発環境)
  • Staging(開発環境を参照しているデプロイゲート更新用)
  • Release(本番)

このように分けました。
今回の納期までのスケジュールではもろもろのパッケージのバージョンアップは行わない方針でいったので後に書きますトラブルに見舞われた可能性があります。。

トラブルシューティング

※エラーを無くすだけなので、その設定はちょっと・・・というのがあるかもしれませんがご了承ください。

error E:\my-gridsome-site\node_modules\sharp: Command failed.Command: (node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)...

1から実装したものでは無くて、他のプロジェクトを取り込む際に起こるエラーなようです。
nodeのバージョンを適切なバージョンに上げて解決。(多分このエラーの下の方に適切なnodeバージョンが書いてあった気がする)
Macでは
sharp: Command failed · Issue #585 · gridsome/gridsome · GitHub
上記だと解消するらしいです。

nodistを入れていたらnpxが使えなくなった話

Windows限定な話な気がしますが
Nodist を入れたら npx が使えなくなったので手動でインストール / Twin Turbo Computing
こちらで解決。

Task :app:processDebugGoogleServices FAILED

cd `{project_name}`
cd android && ./gradlew clean

たまに、上記の対応で直ることがありますが、
firebaseのgoogle-services.jsonが原因の可能性もあります。
環境にdebugとrelease以外の環境が存在するとこのエラーに巡り合えます。
applicationIdSuffix を指定しているかにもよりますが、指定していない場合は

{projectName}/android/app/google-services.json からコピーして
{projectName}/android/app/src/debug/google-services.json を作成します。
パッケージ名はandroid/app/src/main/AndroidManifest.xmlに書いているpackageNameを見ます。
applicationIdSuffixを設定していなければmainに記載している名称と同じで問題ありませんが、設定している場合は
{packageName}.{applicationIdSuffix} になります。これでエラーが無くなります。
applicationIdSuffixを指定している場合はStaging環境にもgoogle-services.jsonが必要だったと思います。

{
  "project_info": {
    "project_number": "...",
    "firebase_url": "...",
    "project_id": "...",
    "storage_bucket": "..."
  },
  "client": [
    {
      "client_info": {
        "mobilesdk_app_id": "...",
        "android_client_info": {
          "package_name": "←ここを環境ごとに合わせる"
        }
      },
・・・

参照:https://noy.hatenablog.jp/entry/2018/02/15/121431

No matching client found for package name

Task :app:processDebugGoogleServices FAILED と同様の対応でなくなりました。
google-services.jsonが適切な場所にないのと、正しいpackage nameになってないことが問題でした。

What went wrong: Execution failed for task ':app:mergeReleaseResources'.

重複エラーだそうです。今思えば
{project_name}/android/app/src/main/assetsにindex.android.bundleが存在しなかったことが原因でしたが以下の方法で解決。
参照:reactjs - React Native 0.57.1 Android Duplicate Resources - Stack Overflow

  1. {project_name}/android/app/build 全部削除
  2. {project_name}/android/build 全部削除
  3. 実行 rm -rf $HOME/.gradle/caches/
  4. {project_name}/android/app/src/main/assetsのindex.android.bundle を削除(この時点で自分のプロジェクトには存在しなかったのでパス)
  5. 以下を実行
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res

この時結構不要なファイルも出たから削除した気がする・・・・
jsonファイルやnode_modulesのもろもろも色々出てきてビルドには不要だったんで削除しました。

Task :app:transformClassesAndResourcesWithR8ForRelease FAILED

transformClassesAndResourcesWithR8ForReleaseの実行に失敗している。
調べてみると諸々のパッケージバージョンが合わないことで発生しているケースがあるらしいです。(多分色々古かったパッケージもあった。バージョンアップはリスキーなので断念)
R8の設定をtrueにすることで難読化や最適化を行ってくれるそうですが、ここではfalseにしていきます。

{project_name}/android/gradle.properties
追記

#Disables R8 for Android Library modules only.
android.enableR8.libraries = false
#Disables R8 for all modules.
android.enableR8 = false

参照:https://developer.android.com/studio/releases

Execution failed for task ':app:transformClassesAndResourcesWithProguardForRelease'. java.io.IOException: Please correct the above warnings first.

以下続き

Warning: there were 1649 unresolved references to classes or interfaces.
         You may need to add missing library jars or update their versions.
         If your code works fine without the missing classes, you can suppress
         the warnings with '-dontwarn' options.

Staging用APKを生成するときに出たエラーです。
Proguard 関連の処理が原因らしいです。この辺を-dontwarnを使って制御することができるらしいです。
とはいえ、1649件もなんかバージョンアップしてないとか、クラスに欠陥があるとか、不足ライブラリがあるらしいものを一気になんとかできるのか。。。(調べてみると、件数が少ないと1個1個バージョンアップやらすることで解消することもあるそうです)
実機ビルドでは普通にアプリが動いていることから、-dontwarnなどをproguard-rules.proを解消できそうであると考え、以下の対応を行っています。

{project_name}/android/app/build.gradleのbuildTypeにstagingで起きたエラーなので、
stagingに proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" を追加
これでproguard-rulesを見に行きます。(多分ここまでたどり着くのに2日くらい)

buildTypes {
        debug {
      ・・・
        }
        staging {
             ・・・
       // add
            proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
        }
        release {
            ・・・
        }
    }

次にproguard-rulesの設定。
{project_name}/android/app/proguard-rules.pro
元々の専任の方が書いてあった記述にプラスしてReactNative系の処理とWarningはスルー、もろもろの処理をスルーしますよ的な書き方だったりを追記。

# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
#   http://developer.android.com/guide/developing/tools/proguard.html

# Add any project specific keep options here:
-keep class io.agora.**{*;}

# THIS IS VERY VERY BAD. REMOVE AS SOON AS VERSIONING IS FIXED
-dontwarn **


-dontnote **

-keep class host.exp.exponent.generated.AppConstants { *; }

##### Crashlytics #####
-keepattributes SourceFile,LineNumberTable

##### React Native #####
-keep,allowobfuscation @interface **.facebook.proguard.annotations.KeepGettersAndSetters
-keep,allowobfuscation @interface **.facebook.react.bridge.ReadableType


-keepclassmembers @**.facebook.proguard.annotations.KeepGettersAndSetters class * {
  void set*(***);
  *** get*();
}

-keep class * extends **.facebook.react.bridge.JavaScriptModule { *; }
-keep class * extends **.facebook.react.bridge.NativeModule { *; }
-keepclassmembers class *  { @**.facebook.react.uimanager.UIProp <fields>; }
-keepclassmembers class *  { @**.facebook.react.uimanager.ReactProp <methods>; }
-keepclassmembers class *  { @**.facebook.react.uimanager.ReactPropGroup <methods>; }


##### Versioned React Native #####
-keep class **.facebook.** { *; }
-keep class abi** { *; }
-keep class versioned** { *; }

本当はもっと書いてあったけど、いらなそうなのがあったんでその辺は削除。
多分、先人がReactNativeのバージョンとか上げてしまったり、その他のパッケージとかもものすごく古いパッケージとかもあったし、色々な苦労が見えた結果かなと思う。これが正しかったのかわからないけど、このエラーはこうすることで消えました。(proguardの設定も意味がわからなくて2日くらいかかって合わせて4日くらいかかってしまった。。)

Execution failed for task ':react-native-orientation:verifyReleaseResources

自分でreact-native-orientationを入れた記憶がないので先人のものでしょう。
参考:Execution failed for task ':react-native-orientation:verifyReleaseResources' · Issue #290 · yamill/react-native-orientation · GitHub

{project_name}/android/gradle.bundle に以下を追加

buildscript{ ...}

allprojects { ...}

subprojects {
    afterEvaluate { project ->
        if (project.hasProperty("android")) {
            android {
                compileSdkVersion 28        // version of compile sdk used for project
                buildToolsVersion '28.0.3'    // version of build tool used for project
            }
        }
    }
}

これは同じ{project_name}/android/gradle.bundleに記載しているbuildscriptにある、compileSdkVersionbuildToolsVersionを合わせないといけないです。
ちなみに、こちらのバージョンも28以上でないといけなかったらしいです。こちらのプロジェクトでは偶然28以上を使ってたので、問題ありませんでした。

Task :@react-native-community_async-storage:generateDebugBuildConfig FAILED

cd android && ./gradlew clean で解消
キャッシュが残っていることがあるらしいです。

Execution failed for task ':app:installDebug'. > com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: INSTALL_FAILED_VERSION_DOWNGRADE

cd android && ./gradlew cleanだけでは直らず、普段実機ビルドできているのにどうして?というエラー
PC再起動で直りました。

Task :app:mergeDebugResources FAILED

このエラーの前後にエラーの原因となっているログが書いているはずで、自分の記憶にないエラー内容だと、
cd android
./gradlew clean
で解決することがある

でも大概は「あ、これ自分触ったやつ」っていうエラーもあるので、そちらに問題があることもあったりしました。

Task 〇〇:app:packageDebug FAILED

cd android
./gradlew clean
で解決することがあるが、この場合はAndroidStudioがメモリを食っていて出来ない場合もあったので、AndroidStudioで
1.キャッシュ消しての再起動
2.build>clean projectで解消

以上。

開発期間が短かったので出来る限りのことをやろうと思って必死に調べました。
使っているパッケージやライブラリが古く、もうドキュメントすら残されてないのが多い中での対応だったので、そこが一番しんどかったです。でもこれでAndroid設定周りの対応学べた気がしますね
次はiOS編を書いていきます。(Androidほどは無いかもしれない)

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

Jetpack ComposeのLaunchedEffectについて

Jetpack Compose で開発する上できっと使うことになるであろう LaunchedEffect についてみていこうと思います。

※この記事は Jetpack Compose 1.0.0-alpha09 時点の内容のため、stable までに大きく変わる可能性があります

LaunchedEffect とは

  • Composable がコンポジションに入る時に実行される関数
  • コンポジションを離れる時に実行している処理をキャンセル
  • 引数の subject が変更された時にも実行している処理をキャンセルして再度実行
  • 実行する処理は CoroutinesScope で行われる

箇条書きすると上記のことを行う関数で、Composable が表示されるタイミングに何か処理を実行したい時に使用します。

LaunchedEffect の使い方

ただ Composable が表示されるタイミングだけ処理をしたい場合は以下のように subject に Unit を指定します。

@Composable
fun SplashScreen(
    onTimeOut: () -> Unit
) {
    LaunchedEffect(Unit) { 
        delay(SplashWaitTime)
        onTimeOut()
    }
    ...
}

subject に値を渡す場合だと、例えば検索時のサジェストの処理に使えたりします。

@Composable
fun SearchScreen() {
    ...
    var searchQuery by remember { mutableStateOf("") }
    LaunchedEffect(searchQuery) {
        // execute search and receive result
    }
    ...
}

LaunchedEffect の実装を覗いてみる

LaunchedEffect のコードをみると subject を remember で保存し、変更があれば処理を再実行するようになっています。

@Composable
@ComposableContract(restartable = false)
fun LaunchedEffect(
    subject: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(subject) { LaunchedEffectImpl(applyContext, block) }
}

LaunchedEffectImpl の方では Composable のライフサイクルを扱う CompositionLifecycleObserver でコンポジションに入った時に処理の実行、離れた時で Job のキャンセルを行っています。

internal class LaunchedEffectImpl(
    parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit
) : CompositionLifecycleObserver {

    private val scope = CoroutineScope(parentCoroutineContext)
    private var job: Job? = null

    override fun onEnter() {
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }

    override fun onLeave() {
        job?.cancel()
        job = null
    }
}

参考

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

【Android】Image Asset Studio でアプリアイコンを作成する方法

プログラミング勉強日記

2020年12月18日
Android Studioでアイコンを作ることができるのを知ったのでその方法をまとめる。

Image Asset Studio でアプリアイコンを作成する

1. ファイル(File)新規(New)画像アセット(Image Asset)を開く

image.png

2. 好きなようにアイコンを作成する

 アイコンタイプ(Icon Type)はLauncher Icons (Adaptive and Legacy)を選択する。テキストの色とフォントを変更するオプションがあるので自由に変更する。

image.png

image.png

3. アイコンを作成したら次へ(Next)完了(Finish)を押す

 次へ(Next)を押すとファイル作成のプレビューが見れる。完了(Finish)でファイルが作成される。

プレビュー画面↓
image.png

注意

 Adaptive Icon:foregroundとbackground画像をアプリが提供すれば、システムがこの2つの画像を組み合わせて使用する概念
 Legacy Icon:Adaptive Iconが出る前のアイコン

 AdaptiveアイコンはAndroidのO(API 26)以上で動作しmipmap-anydpi-v26/ic_launcher.xmlでVectorに実装されているため、解像度ごとに画像ファイルを作成する必要がない。そのため、AndroidのO(API 26)未満のバージョンではLegacyアイコンを提供する必要がある。

参考文献

Image Asset Studio を使用してアプリアイコンを作成する
アンドロイドスタジオでは、Adaptive、一般のアイコンを作成

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

Let's はじめてのRetrofit for Android in Kotlin(サンプルコード付き)

はじめに

これまでOkHttpでサーバとのHTTP通信を行っていたのですが、最近はRetrofitが流行っているじゃないですか。
この記事ではそんなRetrofitの使い方を説明します。

動作環境

この記事の動作環境は以下のとおりです。

Android Studio:4.1
Kotln:1.4.10
Open JDK:1.8
compileSdkVersion:30
targetSdkVersion:30
minSdkVersion:16

目標

以下を目標とします。

  • RetrofitでHTTP通信
  • 文字列としてのJSONを取得する
  • JSON取得時にGsonでコンバートする
  • Retrofitの非同期処理の実装方法

事前準備

本サンプルで使うサーバはNodeJSON Serverを利用しています。
サンプルプロジェクトを動作させるには、事前に用意しておいてください。

アプリの概要

Retrofitを利用して、JSON ServerにGET、POST、PUT、DELETEでHTTP通信するアプリです。
基本的なRESTサーバにアクセスする機能を持ちます。

Retrofitのイメージ

そもそもRetrofitってどうやって動いているのかを理解しなければ、ソースコードを確認しても理解が難しいと思います。

Retrofitの概要.png

登場人物としては以下です。

  • Service(インターフェイス)
  • Retrofit
  • Service(実装クラス)
  • Callクラス

大きな処理の流れとしては以下です。

  1. はじめに、Service(インターフェイス)を定義しておきます。
    どのようなパスにどのようなHTTPメソッドでアクセスするかを定義します。
  2. RetrofitクラスをBuilderクラスを通してインスタンスを取得します。
    インスタンス取得の際にベースとなるURL(http://xxx.xxx.xxx/)を設定します。
  3. RetrofitクラスからService(インターフェイス)の実装クラスを生成します。
  4. Service(実装クラス)からCallクラスを取得します。
  5. CallクラスでHTTP通信を実施します。

JSON文字列でWebAPIにHTTP通信する実装方法

はじめに、文字列として用意したJSONのデータを送信し、受け取るJSONも文字列として取得する実装方法を説明します。

実行イメージ

実行イメージは以下のようになります。
ファイル名

サンプルコード

GitHubからダウンロードしてください。

GETメソッドでHTTP通信する実装方法

コード解説

build.gradle(app)への追記

はじめに、Retrofitのライブラリーをdependenciesに追記します。

build.gradle(app)
dependencies {
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
}

Serviceクラスの定義

サーバに接続するURLのパスやHTTPメソッド、送信するパラメータなどを抽象メソッドとして定義します。

MyService.kt
package jp.co.casareal.retrofitbasic

import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*

interface MyService{

    @GET("posts")
    fun getRawResponseForPosts(): Call<ResponseBody>

    @POST("posts")
    fun postRawRequestForPosts(@Body body:RequestBody):Call<ResponseBody>

    @PUT("posts/{id}")
    fun putRawRequestForPosts(@Path("id") id:String, @Body body:RequestBody):Call<ResponseBody>

    @DELETE("posts/{id}")
    fun deletePathParam(@Path("id") id:String ):Call<ResponseBody>
}

メソッドや引数などにアノテーションを付与することにより様々な設定ができます。

メソッドに付与できるアノテーション

アノテーション 引数 説明
@GET value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。
@POST value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。
@PUT value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。
@DELETE value(String) 引数にパスの設定や送信するパスパラムのパラメータのプレースホルダを{}で記述できます。
引数に@Urlが存在する場合は引数はオプション扱いになります。
@Multipart なし マルチパートで送信する場合に付与します。
@POSTなどと併せてせっていします。
@Header value(String Array) HTTP Headerを設定できます。単一の場合はString型にします。複数ある場合は、文字列配列で付与します。

メソッドの引数に設定できるアノテーション

アノテーション 引数 説明
@Path value(String)
encoded(boolean)
{}で設定したプレースホルダに値を設定します。文字列には{}で指定した文字列を指定します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse)
@Query value(String)
encoded(boolean)
クエリパラメータのキーをStringで指定します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse)
@QueryMap Map
encoded(boolean)
クエリパラメータが複数存在する時に利用できます。クエリーパラメータをキーと値のMapで渡します。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse)
@QueryName value(String) クエリーパラメータでキーのみを設定できます。
@Field value(String) HTTPリクエストメッセージボディに付与する値のキーをStringとして指定します。
@FieldMap Map
encoded(boolean)
HTTPリクエストメッセージボディに付与する値が複数存在する場合に利用できます。booleanには設定する値がエンコード済みかどうかを指定します。(デフォルトfalse)
@Part value(String)
encoding(String)
マルチパートで送信するデータのキーをStringで指定し、エンコードの方式もStringで指定します。(デフォルトbinary)
@PartMap Map
encoding(String)
マルチパートで送信するデータのキーをStringと値をMapで指定し、エンコードの方式もStringで指定します。(デフォルトbinary)
@Header value(String) HTTPヘッダーを設定できます。引数には設定するHeaderを指定します。
@HeaderMap Map HTTPヘッダーを複数設定する時に使用します。キーには設定するHeader、値には設定するヘッダーの値を指定します。

JSONを文字列として取得するには、 RequestBodyResponseBodyOKHttpで提供されているクラスを利用します。詳細はこちらの記事を参照してください。

戻り値はCall型にして、実際にサーバから受け取るデータの型をジェネリクスで指定します。

Retrofitクラスのインスタンス化

Serviceの準備ができたらRetrofitクラスのインスタンスを取得します。

MainActivity.kt
// Retrofit本体
private val retrofit = Retrofit.Builder().apply {
    baseUrl("http://10.0.2.2:3000/")
}.build()

Retrofit.Builder クラスでRetrofitクラスのインスタンスを生成します。
baseUrl プロパティは必須となっています。baseUrlプロパティは okhttp3.HttpUrl でも大丈夫です。

最後にbuildメソッドを呼び出し、インスタンスを取得します。

Service(実装クラス)の取得

RetrofitクラスでService(実装クラス)を取得していきます。

MainActivity.kt
// サービスクラスの実装オブジェクト取得
private val service = retrofit.create(MyService::class.java)

Service(実装クラス)取得には、Javaのクラス情報を渡します。

Callクラスの取得とHTTP通信

Service(実装クラス)から、実際にHTTP通信を行うCallクラスを取得します。
Service(インターフェイス)に定義したメソッドから行いたいHTTP通信にあったCallクラスを取得します。

MainActivity.kt
val get = service.getRawResponseForPosts()

HTTP通信を行うには、下記の2つのメソッドが用意されています。

メソッド名 説明
execute() 同期処理であるため、ワーカスレッドで呼び出す必要がある。
enqueue(Callback callback) HTTP通信が終わったあとに実行したい処理をCallbackクラスで渡す必要あります。コールバック内はMainスレッドで実行されます。

今回は同期処理のため、取得したCallクラスのexecuteメソッドを呼び出します。

MainActivity.kt
scope.launch {
    val responseBody = get.execute()
}

レスポンスから文字列の取得

実行結果はokhttp3.RequestBodyで受け取るため、bodyメソッドでレスポンスの内容を取得します。
サイトにstringメソッドでJSONの文字列を取得します。

MainActivity.kt
responseBody.body()?.let {

    myViewModel.result.postValue(it.string())

}

POSTメソッドやPUTメソッドでJSON文字列を送信する実装方法

ここからのコードの解説は、GETメソッドとの差分のみを説明します。

コード解説

送信するデータを準備

今回はJSON文字列を生成するユーティリティクラスを作成しました。

Util.kt
package jp.co.casareal.retrofitbasic.util

object Util {

    fun createJson(id:String = "",title:String, author:String):String
            ="{" +
            "  \"id\": \"${id}\"," +
            "  \"title\": \"${title}\"," +
            "  \"author\": \"${author}\"" +
            "}"
}

実際に送信する文字列を生成します。

MainActivity.kt
val json = Util.createJson(
    title = myViewModel.title.value!!,
    author = myViewModel.author.value!!
)

RequestBodyのインスタンスを生成する

送信するデータはRequestBody送信するのでオブジェクトを生成します。
RequestBodyについては、こちらの記事を参照してください。
生成したRequestBodyをService(実装クラス)のメソッドの引数に渡し、送信データをCallクラスに格納します。

MainActivity.kt
val requestBody = RequestBody.create(mediaType, json)

val post = service.postRawRequestForPosts(requestBody)

JSON文字列をオブジェクトに自動変換してWebAPIにHTTP通信する実装方法

先までのHTTP通信では送信するデータも文字列で用意し、レスポンスも文字列で受け取るため、データクラスへの変換は実装する必要があります。
しかし、Retrofitにはデータクラスへの変換機能が存在します。
ここからはデータクラスへの自動変換機能の実装方法を説明します。

今回は自動変換させるために、Gsonのconverterを利用します。
Gsonについては、この記事を参照してください。

また、これまでの説明と重複するところは省略します。

実行イメージ

実行イメージは以下のようになります。
ファイル名

[実行イメージ.gif]()

サンプルコード

GitHubからダウンロードしてください。

GETメソッドでHTTP通信する実装方法

さっそくGETメソッドのHTTP通信の実装方法を説明します。

コード解説

build.gradle(app)への追記

はじめに、Retrofitのライブラリーをdependenciesに自動変換の機能であるconverterを追記します。

build.gradle(app)
dependencies {
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}

サーバとやり取りするデータクラスの定義

サーバと送受信するデータクラスを定義しておきます。

Post.kt
package jp.co.casareal.retrofitwithconverter.model

data class Post(

    val id: String = "",
    val title: String,
    val author: String
)

Serviceクラスの定義

Serviceクラスの定義もCallのジェネリクスや引数をデータクラスの方に変更します。

MyService.kt
package jp.co.casareal.retrofitwithconverter

import jp.co.casareal.retrofitwithconverter.model.Post
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*

interface MyService {
    @GET("posts")
    fun getRawResponseForPosts(): Call<List<Post>>

    @POST("posts")
    fun postRawRequestForPosts(@Body post: Post): Call<Post>

    @PUT("posts/{id}")
    fun putRawRequestForPosts(@Path("id") id: String, @Body post: Post): Call<Post>

    @DELETE("posts/{id}")
    fun deletePathParam(@Path("id") id: String): Call<Post>

}

Retrofitクラスの取得

自動変換機能を追加するにはRetrofitクラスを取得するときに、自動変換してくれるconverterをしていします。

MainActivity.kt
// Retrofit本体
private val retrofit = Retrofit.Builder().apply {
    baseUrl("http://10.0.2.2:3000/")
    addConverterFactory(GsonConverterFactory.create())
}.build()

addConverterFactoryメソッドで自動変換するconverterを渡します。
今回は、GsonConverterFactory#createでConverterFactoryを取得して渡しています。

HTTP通信で取得できる型

converterを指定することにより、JSON文字列→データクラスへ自動で変換されます。

そのため、Call#executeメソッドの戻り値でbodyメソッドを呼び出すと、Service(インターフェイス)で指定したデータクラスの型に変換されます。

MainActivity.kt
val list = response.body()

POSTメソッドやPUTメソッドでJSON文字列を送信する実装方法

ここからのコードの解説は、GETメソッドとの差分のみを説明します。

コード解説

基本的に差分としては、引数に送信するデータをデータクラス型にしておいても、送信時にJSONへ自動変換されます。

送信するデータを準備

データクラスは定義済みであるため、データクラスのインスタンスに送信するデータを設定します。

MainActivity.kt
val data = Post(title = myViewModel.title.value!!, author = myViewModel.author.value!!)

Service(実装クラス)のメソッドにデータクラスのオブジェクトを渡す

JSONに自動変換するConverterを設定しているので、データクラスをJSON文字列に自動変換してくれます。
そのため、ここではデータクラスのオブジェクトを渡します。

MainActivity.kt
val post = service.postRawRequestForPosts(data)

その他はこれまで説明した通りの内容です。

HTTPを非同期通信で実行する

これまで通信処理は同期処理をおこなうメソッドで実行していました。しかし、昨今のAndroidでは通信を非同期処理で行います。
もちろんRetrofitも非同期処理に対応したメソッドが用意されています。
移行は非同期処理の実装方法について説明します。

また、これまでの説明と重複するところは省略します。

実行イメージ

実行イメージは以下のようになります。
ファイル名

サンプルコード

GitHubからダウンロードしてください。

非同期処理でHTTP通信を行う実装方法

HTTPメソッドに関わらず、非同期処理の実装方法は共通です。

コード解説

非同期で通信を行う

非同期処理を行うには先に説明したとおり、Call#enqueueを呼び出します。
引数にはCallbackクラスのオブジェクトを渡します。
CallBackクラスは戻り値の型をジェネリクスで指定します。

MainActivity.kt
val get = service.getRawResponseForPosts().also {

            it.enqueue(object : Callback<List<Post>> {
                override fun onFailure(call: Call<List<Post>>, t: Throwable) {
                    Toast.makeText(this@MainActivity, "通信エラー", Toast.LENGTH_SHORT).show()
                    Log.e("TEST", "通信エラー", t)
                }

                override fun onResponse(call: Call<List<Post>>, response: Response<List<Post>>) {
                    myViewModel.result.value = response.body().toString()
                }
            })
        }
}

CallBackクラスには2つのメソッドがあり。
HTTP通信の成否によって実装内容を記述できます。

メソッド名 引数 説明
onFailure Call call
Throwable t
Callは実行したHTTP通信のCallのオブジェクト。Throwableは通信に失敗したときのエラーの内容が格納されています。
onResponse Call call
Response response
Callは実行したHTTP通信のCallのオブジェクト。Responseはexecuteのときと同じResponseオブジェクトです。

まとめ

Retrofitを使ってきましたが、ある程度OKHttpなどを理解していないと難しいところもありますが、非同期処理に対応したメソッドがあり便利だと感じました。

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

モバイルチームの成長とKMM導入に向けて

はじめに

株式会社RevCommでモバイルアプリを担当している木下です。
この記事は 2020年のRevCommアドベントカレンダー 19日目の記事になります。18日目は @zomaphone さんの 【既存の解析システムに対して pytest-mock と pydantic を活用してクイックに総合テストを実装した話】 でした。

MiiTel Phone Mobile アプリについて

開発している MiiTel Phone Mobile アプリの主な機能はVoIPで、RevComm のメインプロダクトである MiiTel のオプションとしてご利用いただけます。当初は Cordova で開発されたβ版アプリが無料で提供されていましたが、アプリを起動していないと着信が受けられないなどの不都合があり、これらのユーザー体験を改善するためにネイティブ(Swift/Kotlin)で開発し直したものが現行のアプリとなります。

モバイルチームについて

モバイルアプリの開発を担うモバルチームの変遷を簡単に紹介します。

1. 一人チーム時代

約2年前にRevCommにジョインしてから、iOSとAndroidの両アプリを開発・リリースし、サポート対応や機能改善、OSアップデート対応などをほぼ一人でやってきました。アプリの実装だけでなく、モバイルアプリのための 通信サーバー の設定やサーバーアプリの改修なども対応してきました。

2. 新メンバー参入

今年の10月ついにモバイルチームに、新しいメンバーが2名加わりました。ともにiOSアプリとAndroidアプリの開発経験があり、どちらも安心して任せられるメンバーです。
私としては フルリモート・フルフレックスタイム制を導入しているRevComm で社員を迎え入れるのが初めてでした。組織として急成長してきたことや、リモートワークを推奨していることにより、コミュニケーションの不足や取りづらさの課題を以前から感じていたため、この点を特に留意し、新しいメンバーが発言・活動しやすい環境にしてプロダクトの成長に貢献できるようなチーム作りを目指しました。具体的な内容については、また機会があれば紹介したいと思います。

3. そしてさらなる成長に向けて

新たなメンバーが加わってチームとして機能するようになり、開発のスピードも上がってきました。これまでのワンオペでは機能を追加するとしても、iOSアプリの実装とリリースをしてから、Androidも同様の対応をするというフローとなり、リリースのタイミングがズレることがほとんどでしたが、メンバーが増えたことで両アプリ同時に新機能をリリースするといったことも可能になってきました。
そして、これからさらにスピードを上げるために導入を検討しているのが、マルチプラットフォーム開発です。
一つの機能を追加するのに、SwiftとKotlinで同様のロジックを実装しなくてはいけない、というのは非効率であると感じており、これを解消するためにもマルチプラットフォーム開発を導入したいと考えています。
React Native、Flutter、KMM(Kotlin Multiplatform Mobile) などありますが、既存のコードを生かしつつ、徐々に共通化が進められそう、かつ言語的に従来のモバイルエンジニアでもスムーズに開発できそうな、KMM が有力であると考えています。私自身 前職 ではAndroidアプリをメインでやっていたこともあり、KotlinでiOSアプリも開発できたら嬉しいなと考えていたところで、 Kotlin Multiplatform Mobile がアルファ段階に移行 というニュースを目にしたのをきっかけに、導入意欲が高まりました。

KMM(Kotlin Multiplatform Mobile) 入門

以前から Kotlin/Native という形でマルチプラットフォーム向けの開発環境は提供されていましたが、KMM ではiOSとAndroidのモバイルアプリに特化し Android Studio などの IDEに統合可能な環境となっており、より簡単に扱えるようになったと認識しています。
個人的にFlutterは少し触っている(Widgetの充実具合や高速な Hot reload は素晴らしいですよね)のですが、KMM は未経験でしたのでこの機会(Advent Calendar)に少し触ってみようと思い、 KMM Shared Module を作成して、AndroidアプリとiOSアプリのそれぞれでインポートして動作させる、ことをやってみようと思います。

1. 開発環境

公式サイト にも記載されていますが、次のような環境が必要となります。

  • Android Studio 4.1 以降
  • Xcode 11.3 以降
  • (AS) Kotlin plugin 1.4.20 以降
  • (AS) Kotlin Multiplatform Mobile plugin
  • JDK

2. Androidプロジェクト作成

KotlinNativeAndroid というプロジェクトを作成しました。
AndPrg.png

3. KMM Shared Module 作成

KotlinNativeAndroid を開いた状態でメニューの File > New > New Module... を選択し KMM Shared Module を選択してから Next をクリックします。
kmm-module.png

次の画面で Generate packFoxXcode Gradle task をチェックしてから Finish をクリックします。(他はデフォルトのままとしました)
kmm-module2.png

次のようなファイルが作成されました。
kmm-module3.png

以下のような内容となっており、OSのバージョンを含む文字列を返すメソッドがサンプルとして作成されていることが分かります。

commonMain/.../Greeting.kt
package com.example.kmmsharedmodule

class Greeting {
    fun greeting(): String {
        return "Hello, ${Platform().platform}!"
    }
}
commonMain/.../Platform.kt
package com.example.kmmsharedmodule

expect class Platform() {
    val platform: String
}
androidMain/.../Platform.kt
package com.example.kmmsharedmodule

actual class Platform actual constructor() {
    actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
iosMain/.../Platform.kt
package com.example.kmmsharedmodule

import platform.UIKit.UIDevice

actual class Platform actual constructor() {
    actual val platform: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}

KotlinNativeAndroid プロジェクト内のファイルも自動で変更されます。

settings.gradle
include ':kmmsharedmodule' <- 追加
gradle.properties
kotlin.mpp.enableGranularSourceSetsMetadata=true <- 追加
kotlin.native.enableDependencyPropagation=false <- 追加

4. Androidプロジェクト修正

app/build.gradle
dependencies {
    implementation project(':kmmsharedmodule') <- 追加
}

KMM Shared Module から文字列を取得して、画面に表示する処理を実装します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMainBinding.inflate(layoutInflater)
        binding.textView.text = Greeting().greeting()
        setContentView(binding.root)
    }
}

ここで実行してみると次のようなエラーが発生しました。

Manifest merger failed : uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [:kmmsharedmodule] /Users/tkinoshita/kotlin-native-example/KotlinNativeAndroid/kmmsharedmodule/build/intermediates/library_manifest/debug/AndroidManifest.xml as the library might be using APIs not available in 23
    Suggestion: use a compatible library with a minSdk of at most 23,
        or increase this project's minSdk version to at least 24,
        or use tools:overrideLibrary="com.example.kmmsharedmodule" to force usage (may lead to runtime failures)

Moduleの minSdkVersion がアプリよりも高いことが原因のようですので、Module の minSdkVersion をデフォルト値の 24 から、アプリの設定と同じ 23 に変更します。また compileSdkVersion と targetSdkVersion もアプリに合わせました。

build.gradle.kts
android {
    compileSdkVersion(30)
    defaultConfig {
        minSdkVersion(23)
        targetSdkVersion(30)
    }

5. Androidアプリ実行結果

正常に実行されると次のような表示となります。

6. Framework 出力

続いてiOSアプリでもModuleを利用していきますが、iOSアプリ(Xcode)で利用するためには、Framework 形式でライブラリを出力する必要があります。
build.gradle.kts の下の方に Framework を出力するためのタスクが記述されているので、これを利用します。
Terminal で次のコマンドを実行します。

./gradlew :kmmsharedmodule:build

成功すると、 kmmsharedmodule/build/xcode-frameworks 内に kmmsharedmodule.framework フォルダが作成され Framework が出力されます。

7. Xcodeプロジェクト作成

次に iOSアプリのプロジェクトを作成していきます。
Xcodeで KotlinNativeiOS という名前の App プロジェクトを新規作成します。
iosPrg.png

8. Xcodeプロジェクト修正

Build SettingsFramework Search Paths に Framework のパスを追加します。ここでは $(SRCROOT)/../KotlinNativeAndroid/kmmsharedmodule/build/xcode-frameworks を設定しています。
スクリーンショット 2020-12-18 6.26.36.png

アプリに Framework を組み込むための設定を追加します。
スクリーンショット 2020-12-18 6.30.17.png

KMM Shared Module から文字列を取得して、画面に表示する処理を実装します。

ViewController.swift
import UIKit
import kmmsharedmodule

class ViewController: UIViewController {

    @IBOutlet weak var textLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        textLabel.text = Greeting().greeting()
    }
}

9. iOSアプリ実行結果

正常に実行されると次のような表示となります。

10. ソースコード一式

こちらのリポジトリ で公開しています。

おわりに

KMM Shared Moduleの作成から AndroidアプリとiOSアプリで利用する流れが把握でき、おおよその感触がつかめました。どこまでロジックが共通化できるかはまだ未知ですが、期待していたとおり簡単にモジュールの作成と利用ができましたので、これから積極的に採用していきたいと思います。

明日はモバイルバックエンド担当の @rhoboro さんです!!
実は @rhoboro さんもモバイルチームの一員としてバックエンドの開発を担当されています!

参考

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

AWS SNSからAndroidにプッシュ通知するためにやったこと、ハマったこと

今関わっているプロジェクトのバックエンドはAWSです
そんなAWSからユーザが操作するAndroidにプッシュ通知がしたいと思いました
しかしFirebase(FCM)が便利すぎて逆にハマってしまったのでメモしておきます

プッシュ通知なんてどうせみんなオフするからいらん!という漢気も私は好きです

参考にした素晴らしい記事様はこちら
Android から Amazon SNS を使ってみる - Qiita
Android端末で、FCM経由でAWSSNSを受け取るまで - Qiita

目指すもの

スクリーンショット 2020-12-18 9.40.06.png
まずは最小限、プッシュ通知をすることだけを目標にします
細かい設定や他の人の方が詳しい!

用意するもの

  • 適当なAndroidプロジェクト
  • Firebaseアカウント ※無料プランでOK
  • AWSアカウント

FirebaseからAndroidに通知するまで

適当にAndroidプロジェクトを作成する

スクリーンショット 2020-12-18 9.00.37.png
パッケージ名は後ほど必要です

Firebaseにアカウントを作成、適当なプロジェクトを作る

スクリーンショット 2020-12-18 8.53.42.png
「プロジェクトを追加」から適当な名前でプロジェクトを作成します
ちなみにFCM(Firebase Cloud Messaging)はだけなら無料プランでもよさそうです。素敵。

Firebase Cloud MessagingにAndroidアプリを登録する

公式がとても丁寧に案内してくれるのでそれに従います

Androidパッケージを登録する

スクリーンショット 2020-12-18 9.09.23.png
Androidパッケージ名に、先ほど作成したAndroidプロジェクトのパッケージ名を入力します
署名はよりセキュリティを高めるなら入れた方がいいんでしょうね。今回は省略!

appディレクトリにJSONファイルを追加

スクリーンショット 2020-12-18 9.14.08.png
firebaseからダウンロードしたgoogle-services.jsonをapp以下に追加します

Androidプロジェクトに依存関係を追加する

/build.gradle
buildscript {
    ext.kotlin_version = "1.4.21"
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.1.1"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "com.google.gms:google-services:4.3.4"  // <<<<< added
        ~
/app/build.gradle
plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'com.google.gms.google-services' // <<< added
}
// ~略~
dependencies {
    // ~略~
    implementation platform('com.google.firebase:firebase-bom:26.1.1')  // <<< added
    implementation 'com.google.firebase:firebase-analytics-ktx'         // <<< added
    implementation 'com.google.firebase:firebase-messaging-ktx'         // <<< added
}

FCMにAndroidデバイスを登録する

通知用のデバイストークンを取得する

プッシュ通知するためには、Androidデバイスを一意に特定するためのトークンが必要になります
トークンの発行はAndroidプロジェクトに組み込まれたFirebaseがやってくれます
例えば下記のようにtokenをログ出力するようにしてみます

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

        // ↓added
        FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener {
            if (!it.isSuccessful) {
                Log.e("Firebase", "Fetching FCM registration token failed", it.exception)
                return@OnCompleteListener
            }

            val token = it.result
            Log.d("Firebase Token", token.toString())
        })

    }
}

この状態でアプリを動かすと、ログにトークンが吐かれますので、これをメモしておきます

D/Firebase Token: c3ilo ~~~

FCMからプッシュ通知を送信してみる

スクリーンショット 2020-12-18 9.25.20.png
FCMのダッシュボードから「通知の作成」へ行き、早速メッセージを送ってみます

スクリーンショット 2020-12-18 9.38.33.png
FCM登録トークンに、先ほどAndroidから生成されたトークンを登録して、テストメッセージを送信します

スクリーンショット 2020-12-18 9.40.06.png
すると無事届きました!簡単ですねー
さあFirebase > Android間の連携ができたので、次はAWS SNSとの連携だ

注意:アプリがバックグラウンドでないと通知が届かない

デフォルトの状態だと、アプリがフォアグラウンド中(前面表示)には届かないようです
「君もうアプリ開いてるから通知せんでええやろ?」ってことでしょうか

AWS SNSからAndroidに通知するまで

AWS SNSとFirebaseを紐付ける

AWS公式が非常に親切に手順を説明してくれていますので、基本はこの通りに

Firebaseのサーバーキーを取得する

スクリーンショット 2020-12-18 10.54.14.png
プロジェクトの設定→「Cloud Messaging」からサーバーキーを取得します

FirebaseサーバーキーをAWS SNSに登録する

image.png

  • AWSコンソールからSimple Notification Serviceを開く
  • 左バーの「プッシュ通知」からモバイルプッシュ通知画面を開き、「プラットフォームアプリケーションの作成」
  • プッシュ通知プラットフォームは「FCM」
  • APIキーに先ほど取得したFirebaseサーバーキーを入力する
  • そのほかは空欄でOK

アプリケーションエンドポイントを作成する

image (1).png

  • デバイストークンにAndroidから取得したトークンを入力する
  • そのほかは空欄

いざSNSメッセージ送信!しかし全く反応なし

image (2).png

なにせFirebaseからAndroidへのプッシュ通知は成功しているので、AWS SNSとFirebase間の紐付けがうまくいってない?
と思ってサーバーキー周りをめちゃくちゃ確認しましたが、原因は別のところにありました
それでは、トラブルシュート編に続きます

AWS SNSからAndroidに通知するために追加でやるべきこと

送信するメッセージにはフォーマットがある

AWS SNSをちょっと知っている人には当たり前だと思いますが...
Firebaseで受け取れるようなメッセージを送るにはフォーマットがあります
しかもAWS側がテンプレートを用意してくれています(カスタムペイロード)

image (3).png

しかし、これでもAndroidにはプッシュ通知されませんでした

Android側でレシーバを実装する必要がある

Firebase公式にはしっかりとこんな説明があります

フォアグラウンド アプリで通知メッセージまたはデータ メッセージを受信する場合は、onMessageReceived コールバックを処理するコードを記述する必要があります。

FirebaseからAndroidへの通知は成功していたので、動くと思っていましたが甘かったようです

Androidマニフェストに通知受信用のサービスを追加

マニフェストファイルのタグ以下にサービスを追加します

AndroidManifest.xml
<service
     android:name=".MyFirebaseMessagingService"
     android:exported="false">
     <intent-filter>
          <action android:name="com.google.firebase.MESSAGING_EVENT" />
     </intent-filter>
</service>
MyFirebaseMessagingService.kt
class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)

        Log.d("onMessageReceived", "From: ${message.from}")
        if (message.data.isNotEmpty()) {
            Log.d("onMessageReceived", "payload: ${message.data}")
        }
    }
}

しかしこれでもプッシュ通知が飛んでこないなーと、何気なくログを見てたら
log_image.png
来てたァ!!

カスタム通知を作成する

公式のFCMメッセージについてを参照する限り、AWS SNSからのメッセージはデータメッセージとして扱われていて、
その場合はクライアント側で処理をする必要があるとのこと
(SNSから送信するメッセージにdataって付けてますしね)

MyFirebaseMessagingService.kt
class MyFirebaseMessagingService : FirebaseMessagingService() {
    val CHANNEL_ID = "MyNotification"

    override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)

        Log.d("onMessageReceived", "From: ${message.from}")
        if (message.data.isNotEmpty()) {
            Log.d("onMessageReceived", "payload: ${message.data}")
        }

        createNotificationChannel()
        val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("通知がきたで")
            .setContentText("内容は「${message.data}」やで")
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
        with(NotificationManagerCompat.from(this)) {
            notify(UUID.randomUUID().hashCode(), notificationBuilder.build())
        }
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val name = "MyNotificationChannelName"
            val descriptionText = "MyNotificationChannelDescription"
            val importance = NotificationManager.IMPORTANCE_DEFAULT
            val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
                description = descriptionText
            }
            // Register the channel with the system
            val notificationManager: NotificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }
}

正直ここの実装はかなりみようみまねで適当なので、ご使用の際は十分に注意してください
ちなみにこのとき、アイコンなど一部の設定を省略するとランタイムでクラッシュしたりします

無事プッシュ通知ができました! :tada: :tada:

スクリーンショット 2020-12-18 12.01.21.png

ということで無事通知が動きました!
まだ通知周りは詰められてないところもありますが、なんとかなりましたね

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

Android TVアプリ開発入門 〜あったかいおふとんから出ない生活を夢見て〜

この記事は、株式会社エイチームフィナジーAdvent Calendar 2020 20日目の記事です。

本日は、ナビナビ証券の開発に携わっている@kazenomachiが担当します。

はじめに

2020年12月。

季節は冬、人々の不安を煽るCOVID-19、むやみに外出もできない世の中…

あったかいおふとん :bed: から出ない生活を夢見た私は、ブラックフライデーセールでXGIMI Haloというプロジェクターを買いました :money_with_wings:

XGIMI HaloにはAndroid TVが搭載されており、何も接続しなくても様々なアプリで動画や音楽を再生でき、とても便利です。

動画を映すだけでも十分に便利で楽しいのですが、合間に天気や予定を確認するのに、毎回おふとん :bed: から腕を出してスマホを手に取るのは煩わしいなと感じました。

そこで、時計などのダッシュボードを表示するAndroid TVアプリを作りたいと思ったので自分で開発をしてみました。

Androidアプリを作ったことのない私が、どのように開発を進めたかを書き記したいと思います。

今回作ったもの

時計、現在の天気、今日の予定一覧を表示するダッシュボードを作りました。
一枚の画面に情報を表示するだけのとてもシンプルなアプリです。
Screenshot 2020-12-15 at 11.17.31.png
実際にスクリーンに映すとこんな感じです!
(およそ90インチの大画面に映っています)
20201215_115427.GIF
今回書いたソースコードはGitHubに公開しています。
https://github.com/kazenomachi/Time

開発準備

以下の環境で開発を行います。
Android Studioのインストール方法は別の記事に書きました。
ChromebookにAndroid Studioをインストールして日本語化する

開発環境

  • Pixelbook Go (Core i5)
  • Chrome OS (86.0.4240.199)
  • Linuxインストール済み (Debian GNU/Linux 10 (buster))
  • Android Studio (4.1.1 for Chrome OS)

プロジェクトを作成し、画面を表示する

Android Studioを開いて、プロジェクトを作成します。

プロジェクトのテンプレートで、「Android TV」タブの「No Activity」を選択します。
(Blank Activityも選択してみましたが、最初から多くの画面がありリッチな感じで私には難しくて諦めました)
image.png
(日本語化したら文字化けしてしまいましたが)プロジェクト名や保存先などを指定します。
デフォルトで言語は「Kotlin」SDKは「API 21: Android 5.0 (Lollipop)」が選択されていたので、そのまま「完了」を押下してみました。
image.png
(余談ですが、私のSpotifyのMy Top Songs 2020で宇多田ヒカルの「Time」が3位だったのでTimeにしてみました。いい曲です。)

ここまでできたら、新しいプロジェクトが開きます。

まずは、メイン画面を作成します。

左側のペインで右クリックをして、新規 > アクティビティー > 空のアクティビティー を選択します。
Screenshot 2020-12-05 at 17.39.02.png
色々ファイルができるので、以下のように編集しました。
(事前にpackage名をcom.example〜からio.github.kazenomachi.timeに変更しています。)

app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="io.github.kazenomachi.time">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.Time">
        <activity android:name=".TimeActivity">
            <!-- intent-filterを追加 -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
package io.github.kazenomachi.time

import android.app.Activity
import android.os.Bundle

class TimeActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_time)
    }
}
app/src/main/res/layout/activity_time.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=".TimeActivity">

    <!-- 文字を表示してみる -->
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="なんか文字を表示するよ"
        tools:layout_editor_absoluteX="310dp"
        tools:layout_editor_absoluteY="164dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

これで早速実行してみましょう。
画面右上で「Virtual Device」を選択して、実行ボタンを押下します。
Screenshot 2020-12-05 at 17.44.21.png
しばらく待ってビルドが完了すると、無事にエミュレータが起動して文字が表示されました!!!わーい
image.png

プロジェクターに映して(おふとんから出ずに)デバッグする

せっかくなので、実機を使って(あったかいおふとん :bed: から出ずに)デバッグしてみましょう。

Android TVの開発者向けオプションを設定

以下の通りに実行します。
https://developer.android.com/training/tv/start/start?hl=ja#run

まずは、USBでPCをプロジェクターに接続します。

Android TV側で、「設定」を開き、「デバイス設定」を選択します。
「端末情報」を選択し、「ビルド」を連打します。
そのうち「開発者向けオプションが有効になりました」と表示されると思います。

「デバイス設定」に戻って、「開発者向けオプション」を選択し、「デバッグ」項目の「USBデバッグ」を有効にします。

Chrome OSのADBデバッグを有効にする

Chrome OS側で、ADBデバッグを有効にしておく必要があります。
以下に記載の手順でできます。
https://developer.android.com/topic/arc/development-environment?hl=ja#enable_adb_debug

adbをインストールして、Android TV(実機)に接続する

Chrome OS側で、adbをインストールします。

$  sudo apt install adb

次に、実機のIPアドレスを確認して、接続します。
実機にUSBを接続した状態で、adb connectします。

$ adb connect [実機のIPアドレス]

接続を終了するときは、adb disconnectで切断できます。

$ adb disconnect [実機のIPアドレス]

adb connectした状態で、Android StudioのRunning devicesの選択項目を見ると、実機が選択できるようになっています!
Screenshot 2020-12-05 at 18.05.23.png
早速、実機で実行してみましょう。
image.png
映りました!!:tada:
(文字ちっっちゃ!!!!!!!)

これであったかいおふとん :bed: から出ずにデバッグできる環境が整いました。

メイン画面に時計を表示する

先ほど作成したメイン画面に、時計を表示してみたいと思います :clock2:

画面上に、「日付」と「時刻」を表示できるようにします。

src/main/res/values/strings.xmlに、表示する文字列を追加します。

app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">Time</string>
    <string name="date_format">yyyy/MM/dd</string>
    <string name="time_format">kk:mm:ss</string>
</resources>

src/main/res/layout/activity_time.xmlを開き、TextViewを追加します。
先程追加した、@string/date@string/timeの文字列を表示するようにします。

レイアウトはデザインタブで色々触ってみましたが、よくわかりませんでした。。
(けっこう難しいです)
まずは表示できるようにしたいので適当にパーツを作っていきます。
image.png
コードは以下のようになりました。

app/src/main/res/layout/activity_time.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=".TimeActivity">

    <TextView
        android:id="@+id/textDate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="451dp"
        android:layout_marginTop="142dp"
        android:layout_marginEnd="451dp"
        android:text="@string/date_format"
        android:textAlignment="center"
        android:textSize="69sp"
        android:typeface="normal"
        app:layout_constraintBottom_toTopOf="@+id/textTime"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.756" />

    <TextView
        android:id="@+id/textTime"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/time_format"
        android:textAlignment="center"
        android:textSize="69sp"
        android:typeface="normal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.604" />
</androidx.constraintlayout.widget.ConstraintLayout>

次に、TimeActivity.ktを以下のように変更して、上記で作成した@+id/textDate@+id/textTimeの文字を更新するようにします。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
class TimeActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_time)

        // 時計
        setTimer()
    }

    private fun setTimer() {
        // メインスレッドでHandlerのインスタンスを生成しておく
        val mainHandler: Handler = Handler(Looper.getMainLooper());

        // 1秒ごとに時間を更新する
        timer("clock", period=1000) {
            // mainHandler(UI Thread)にテキストの更新を投げる
            mainHandler.post(Runnable() {
                // 現在日時を取得
                val date = Date()
                // textDateに表示する日付を更新
                findViewById<TextView>(R.id.textDate).text = DateFormat.format(getString(R.string.date_format), date).toString()
                // textTimeに表示する時刻を更新
                findViewById<TextView>(R.id.textTime).text = DateFormat.format(getString(R.string.time_format), date).toString()
            })
        }
    }
}

これで実行してみると、時計が表示されました :tada:

iOS の画像.gif

天気を表示する

image.png

OpenWeatherMapのAPIから、現在の天気を取得して表示してみたいと思います。

事前にアカウントを作成し、APIキーを取得しておきます。

まずは、APIにアクセスするための関数を定義します。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
private fun fetch(url: URL, headers: Map<String, String> = mapOf()): String {
    // 受け取ったURLに接続する
    val connection = url.openConnection() as HttpURLConnection
    headers.forEach { (k, v) -> connection.setRequestProperty(k, v) }
    connection.connect()

    // 1行ずつ取得して文字列を返す
    val reader = BufferedReader(InputStreamReader(connection.inputStream))
    val buffer = StringBuffer()
    var line: String?
    while (true) {
        line = reader.readLine()
        if (line == null) break
        buffer.append(line)
    }
    return buffer.toString()
}

次に、APIを叩いて文字列を変更する処理を作っていきます。
APIを叩くとJSONが返ってくるので、parseして取得した文字列を、先程の時計の実装と同様、TextViewに置き換えるようにします。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
private fun setCurrentWeather(mainHandler: Handler) {
    val apiKey = "APIキーを入れるよ" // イケてないけどとりあえず
    // 大阪市の緯度、経度
    val latitude = "34.6937"
    val longitude = "135.5021"
    val url = URL("https://api.openweathermap.org/data/2.5/onecall?lat=$latitude&lon=$longitude&exclude=minutely,hourly&appid=$apiKey&lang=ja&units=metric")

    val executor: ExecutorService = Executors.newSingleThreadExecutor()
    executor.execute(Runnable() {
        // OpenWeatherMapのOne Call APIを叩いて、天気を取得する
        val result = fetch(url)
        val resultJSON = JSONObject(result)

        mainHandler.post(Runnable() {
            // 現在の天気
            val current = resultJSON.getJSONObject("current")
            // 現在の気温
            val currentTemp = current.getString("temp")
            // 取得した気温をビューのテキストに表示する
            findViewById<TextView>(R.id.currentTemp).text = "$currentTemp℃"
        })
    })
}

OpenWeatherMapでは、天気のアイコンも配信されています。
Picassoというライブラリを使って、配信されているアイコンを取得し、画面に表示しようと思います。

PicassoはREADMEに記載のとおり、Gradleでインストールを行いました。

ソースコードを以下のように編集し、アイコンを表示できるようにします。
ついでに、日本語の現在の天気も表示できるようにしました。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
private fun setCurrentWeather(mainHandler: Handler) {
    val apiKey = "APIキーを入れるよ"
    // 大阪市の緯度、経度
    val latitude = "34.6937"
    val longitude = "135.5021"
    val url = URL("https://api.openweathermap.org/data/2.5/onecall?lat=$latitude&lon=$longitude&exclude=minutely,hourly&appid=$apiKey&lang=ja&units=metric")

    // 5分ごとに現在の天気を更新する
    timer("clock", period=1000 * 60 * 5) {
        val executor: ExecutorService = Executors.newSingleThreadExecutor()
        executor.execute(Runnable() {
            // OpenWeatherMapのOne Call APIを叩いて、天気を取得する
            val result = fetch(url)
            val resultJSON = JSONObject(result)

            mainHandler.post(Runnable() {
                // 現在の天気
                val current = resultJSON.getJSONObject("current")
                val weather = current.getJSONArray("weather").getJSONObject(0)

                // 現在の天気を表すアイコン
                val currentWeatherIcon = weather.getString("icon")
                val imgView: ImageView = findViewById(R.id.currentWeatherIcon)
                setWeatherIcon(currentWeatherIcon, imgView)

                // 現在の天気(日本語)
                val currentWeatherDescription = weather.getString("description")
                findViewById<TextView>(R.id.currentWeather).text = currentWeatherDescription

                // 現在の気温
                val currentTemp = current.getString("temp")
                findViewById<TextView>(R.id.currentTemp).text = "$currentTemp℃"
            })
        })
    }
}

private fun setWeatherIcon(weather: String, imgView: ImageView) {
    // OpenWeatherMapのWeather iconsを取得し、表示する
    val fileName = "$weather@4x.png"
    val url = "https://openweathermap.org/img/wn/$fileName"

    // 指定したURLの画像に置き換える
    Picasso.get().load(url).into(imgView)
}

さいごに、上記で指定した内容を画面に反映します。
activity_time.xmlに、以下のコンポーネントを追加すると、天気が表示されるようになります!

app/src/main/res/layout/activity_time.xml
    <!-- タイトル -->
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="68dp"
        android:layout_marginTop="4dp"
        android:text="WEATHER"
        android:textAlignment="viewStart"
        android:textColor="#8A817C"
        android:textSize="24sp"
        app:layout_constraintStart_toEndOf="@+id/textTime"
        app:layout_constraintTop_toTopOf="@+id/textDate" />

    <!-- 天気アイコン -->
    <ImageView
        android:id="@+id/currentWeatherIcon"
        android:layout_width="200dp"
        android:layout_height="wrap_content"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.142"
        app:layout_constraintStart_toEndOf="@+id/textTime"
        app:layout_constraintTop_toBottomOf="@+id/textView"
        app:layout_constraintVertical_bias="0.084" />

    <!-- 気温 -->
    <TextView
        android:id="@+id/currentTemp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAlignment="viewStart"
        android:textColor="#F4F3EE"
        android:textSize="56sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.126"
        app:layout_constraintStart_toEndOf="@+id/currentWeatherIcon"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.192" />

    <!-- 現在の天気 -->
    <TextView
        android:id="@+id/currentWeather"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAlignment="viewStart"
        android:textColor="#F4F3EE"
        android:textSize="36sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.108"
        app:layout_constraintStart_toEndOf="@+id/currentWeatherIcon"
        app:layout_constraintTop_toBottomOf="@+id/currentTemp"
        app:layout_constraintVertical_bias="0.0" />

TimeTreeの予定を表示する

Screenshot 2020-12-15 at 11.17.31.png

TimeTree APIのPersonal access token(個人でAPIを試すことを目的としたアクセストークン)を使って予定を表示してみようと思います。

今回は予定の書き込みはしないので、読み取り権限のみのトークンを作成しました。

Screenshot 2020-12-12 at 17.35.50.png

APIのトークンを作成したら、早速予定を取得してみたいと思います。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
class TimeActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_time)

        // メインスレッドでHandlerのインスタンスを生成しておく
        val mainHandler: Handler = Handler(Looper.getMainLooper());

        // 時計
        setTimer(mainHandler)
        // 天気
        setCurrentWeather(mainHandler)
        // 予定を追加する
        setSchedule(mainHandler)
    }

    // 以下を追加
    private fun setSchedule(mainHandler: Handler) {
        val timeTreeApiKey = "APIキーを指定するよ"
        val timeTreeCalendarId = "カレンダーのIDを指定するよ"
        // 1日分の予定を取得するようパラメータを指定
        val url = URL("https://timetreeapis.com/calendars/$timeTreeCalendarId/upcoming_events?timezone=Asia/Tokyo&days=1&include=creator,label,attendees")
        val headers: Map<String, String> = mapOf(
                Pair("Accept", "application/vnd.timetree.v1+json"),
                Pair( "Authorization", "Bearer $timeTreeApiKey")
        )

        val executor: ExecutorService = Executors.newSingleThreadExecutor()
        executor.execute(Runnable() {
            val result = fetch(url, headers)
            val resultJSON = JSONObject(result)
            val results = resultJSON.getJSONArray("data")

            var schedules: ArrayList<Schedule> = ArrayList()

            for (i in 0 until results.length()) {
                val schedule = results.getJSONObject(i)
                val attributes = schedule.getJSONObject("attributes")

                // 予定のタイトル
                val title = attributes.getString("title")
                // 終日予定かどうか
                val allDay = attributes.getBoolean("all_day")
                // 開始日時
                val startAt = attributes.getString("start_at")
                // 終了日時
                val endAt = attributes.getString("end_at")

                // 開始日時、終了日時のフォーマットをHH:mmの形式にする
                val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
                dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))
                val timeFormat = SimpleDateFormat("HH:mm")
                timeFormat.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo"))
                val startTime: String? = timeFormat.format(dateFormat.parse(startAt)).toString()
                val endTime: String? = timeFormat.format(dateFormat.parse(endAt)).toString()

                // Scheduleのデータクラスを作成し、schedulesのArrayListに追加する
                schedules.add(Schedule(title, startTime, endTime, allDay))
            }
        })
    }

予定のデータクラスも作成しておきます。

app/src/main/java/io/github/kazenomachi/time/Schedule.kt
data class Schedule(val title: String, val startTime: String?, val endTime: String?, val allDay: Boolean)

上記で取得した予定を画面に表示したいのですが、予定は他の項目と違い、表示する項目数が決まっていません。

そのため、今までのようにTextViewの文字を置き換えるという方法ではなく、RecyclerViewというウィジェットを使って予定を表示します。

RecyclerViewは少し難しい概念で挫折しかけたのですが、こちらの動画を見て概念を理解できました。
Android Kotlin RecyclerView Tutorial
(この記事ではRecyclerViewについて詳しく解説はしないので、最初に見ておくと分かりやすいかもしれません)

まずは、レイアウトにRecyclerViewを追加します。

app/src/main/res/layout/activity_time.xml
<TextView
    android:id="@+id/todaySchedule"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="44dp"
    android:text="TODAY'S SCHEDULE"
    android:textAlignment="viewStart"
    android:textColor="#8A817C"
    android:textSize="24sp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/textTime"
    app:layout_constraintVertical_bias="0.167" />

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/schedulesView"
    android:layout_width="982dp"
    android:layout_height="204dp"
    android:layout_marginVertical="10dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/todaySchedule"
    app:layout_constraintVertical_bias="0.558" />

リスト内に表示する子項目のレイアウトも追加します。

app/src/main/res/layout/schedule.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="wrap_content"
    android:paddingHorizontal="42dp"
    android:paddingVertical="10dp"
    android:focusable="true">

    <TextView
        android:id="@+id/startTime"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="#BCB8B1"
        android:textSize="14sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.0" />

    <TextView
        android:id="@+id/endTime"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="#BCB8B1"
        android:textSize="14sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/startTime"
        app:layout_constraintVertical_bias="1.0" />

    <View
        android:id="@+id/verticalLine"
        android:layout_width="3dp"
        android:layout_height="34sp"
        android:background="#E0CA3C"
        android:layout_marginVertical="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.011"
        app:layout_constraintStart_toEndOf="@+id/startTime"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAlignment="viewStart"
        android:textColor="#BCB8B1"
        android:textSize="28sp"
        android:layout_marginLeft="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/verticalLine"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="1.0" />

</androidx.constraintlayout.widget.ConstraintLayout>

TimeActivity.ktで追加したsetSchedule()内で、上記で追加したRecyclerViewを読み込んで、Adapterを設定します。

app/src/main/java/io/github/kazenomachi/time/TimeActivity.kt
private fun setSchedule(mainHandler: Handler) {
    // 〜省略〜

    val executor: ExecutorService = Executors.newSingleThreadExecutor()
    executor.execute(Runnable() {
        // 〜省略〜

        for (i in 0 until results.length()) {
            // 〜省略〜
        }

        // 以下を追加
        mainHandler.post(Runnable() {
            findViewById<RecyclerView>(R.id.schedulesView).also { recyclerView: RecyclerView ->
                recyclerView.adapter = SchedulesAdapter(this, schedules)
                recyclerView.layoutManager = LinearLayoutManager(this)
            }
        })
    })
}

Adapterは以下のように作成します。

app/src/main/java/io/github/kazenomachi/time/SchedulesAdapter.kt
class SchedulesAdapter(private val context: Context, private val schedules: ArrayList<Schedule>) : RecyclerView.Adapter<SchedulesAdapter.SchedulesViewHolder>() {
    class SchedulesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        // 反映するコンポーネントをViewHolderに定義する
        val startTime: TextView = view.findViewById(R.id.startTime)
        val endTime: TextView = view.findViewById(R.id.endTime)
        val title: TextView = view.findViewById(R.id.title)
    }

    // ViewHolderを作る
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchedulesViewHolder =
        SchedulesViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.schedule, parent, false))

    // 表示する項目数を返す
    override fun getItemCount(): Int = schedules.size

    // 1つの項目に表示する内容を定義する
    override fun onBindViewHolder(holder: SchedulesViewHolder, position: Int) {
        // 予定のタイトル
        holder.title.text = schedules[position].title
        if (schedules[position].allDay) {
            // 終日予定の場合
            holder.startTime.text = "終日"
            holder.endTime.text = ""
            holder.endTime.visibility = View.INVISIBLE
        } else {
            // 開始時刻、終了時刻が指定されている予定の場合
            holder.startTime.text = schedules[position].startTime
            holder.endTime.text = schedules[position].endTime
        }
    }
}

ここまでできたら、画面上に予定が表示されます!

開発したアプリを実機にインストールして使う

最後に、今回開発したアプリを実機にインストールできるようにします。

ビルドしてAPKファイルを作成し、インストールする流れは通常のAndroidアプリと同じです。
方法は色々ありますが、私はadbコマンドでインストールしました。

私の場合、これだけではホーム画面に表示されず、詰まってしまいました。

確認したところ、インテントフィルタの指定が誤っていたようなので、以下の通り修正したところ、無事にホーム画面にアプリが表示されるようになりました!

app/src/main/AndroidManifest.xml
- <category android:name="android.intent.category.LAUNCHER" />
+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />

(公式ガイドの一番最初にしっかり書いてありましたが完全に見落としていました:yum:
https://developer.android.com/training/tv/start/start?hl=ja
9FADDBF2-439D-480B-A5B7-45BE79D6A7D6.jpeg
今回はアイコンを作っていないのですが、アイコンを作ればこの画面にも反映されます。

さいごに

普段はRailsでWebのバックエンドの開発をしており、アプリ開発は未知の世界でした。
でも、想像していたよりも難しくなく、楽しく開発できました。

レイアウト周りが少し難しく感じましたが、もう少し理解が進んだらもっとおもしろいアプリを開発できそうです。

今回書いたソースコードはGitHubに公開しています。
https://github.com/kazenomachi/Time
皆さんもこの冬、自分だけのダッシュボードを作ってみてはいかがでしょうか :snowman:
プロジェクターは生活を豊かにするので、自分へのクリスマスプレゼントにおすすめです :santa: :gift:

この記事を書くにあたり、あったかいおふとん :bed: で開発を行い、執筆しました。
あまりあったかいおふとん :bed: から出ない生活を送っていると、体を痛めるので、時々運動することをおすすめします。
私は肩と腰を痛めました。

参考にしたサイトなど

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

AndroidでOne Touch(確認無し)で電話する方法(Java)

ネットで、確認なしで電話をかける方法をいくら調べても、

Intent intent = new Intent(Intent.ACTION_DIAL, "tel:0123345678);
のACTION_DIALのところを
Intent intent = new Intent(Intent.ACTION_CALL, "tel:012345678");
のようにACTION_CALLにするだけでOKと書いてあるのに、実行するとなぜか落ちる(?_?)

もちろん、AbdroidManifest.xmlに、
uses-permission android:name="android.permission.CALL_PHONE"
を入れるのも忘れてないし、なぜだろうと悩んだところ、
アプリにCALL_PHONEへのアクセス権を与えてやらなければいけないことが判明。

アプリ起動時に、以下のようにアクセス権を与える許可ダイアログを表示してやらなければならなかった。
CALL_PHONEはdangerousパーミッションだから、ユーザにいちいち許可をもらわなければならないのね(^^;)

public class MainActivity extends AppCompatActivity {
static final int REQUEST_CODE = 1;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CALL_PHONE)==PackageManager.PERMISSION_DENIED) {
        ActivityCompat.requestPermissions(
                this, new String[] { Manifest.permission.CALL_PHONE }, REQUEST_CODE);
    }

@Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
    if (requestCode == REQUEST_CODE) {
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            // Dangerous パーミッションのリクエストが許可
        } else {
            Toast toast = Toast.makeText(getApplicationContext(), "電話機能のアクセス権限を追加しないと使えません", Toast.LENGTH_LONG);
            toast.show();
            finish();
        }
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidアプリにVisual Regression Test導入を目指す 第一回 Android instrumented testで自動でスクショを撮る

この記事は Voicy Advent Calendar 2020 の17日目の記事です。
前日は @yamagenii さんの Goでmainが実行される様子を追ってみる でした。

はじめに

2日目に同じAndroidチームの ぬまさん(@moonum)がUnit testの内容を書いてくれたように、VoicyのAndroidチームでは「テストをしっかり書いていこう!」という空気感になりつつあります。
我々Androidチームは全員が8月9月にジョインしたばかりなのですが、いざプロジェクトファイルを覗いてみるとほとんどテストコードが無い…!!ということが発覚したのが発端です。
テストコードがほとんど無くともVoicyのアプリの品質が一定以上を維持されていたのは、ひとえにQAの方々の高品質なチェックによるものだったのです。
(事実、リリース前の動作確認では「本当に人力で見つけたのか?」と思うようなレアケースの不具合を検出していただくことも少なくありません)

どんなにテストをしっかり実装したところで最終的な人力チェックは確実に必要ではありますが、できるだけ自動化できるところは自動化して、手動でしか確認できないところに注力していただきたいところ。
そんなこんな思っているときに、先日ぬまさんにVisual Regression Testをちらっと教えてもらいました。
確かに自動でスクショ撮って修正前後で比較すればデグレチェックが一気に楽になる…!

というわけで今後VoicyのAndroidアプリでVisual Regression Testを導入するとなったときに先駆者となれるよう、お試しで動かしてみることにしました!

今回やること

「Visual Regression Test」を導入するにあたって、大きく工程を分けると

  • 画面ごとのスクリーンショットの自動化
  • 画像比較の自動化

に分かれます。今回は前半戦ということで、スクショをテストコード上で取得していきますよ〜

先駆者の方がいらっしゃったので、その記事を見つつ試しました。

『Finnovalley Developers Blog - MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)』
https://finnovalley.hatenablog.com/entry/2020/11/18/112425

基本的にはこちらの記事の内容を踏まえつつ、詰まったところをつけたそうと思います。

(ちなみにタイトルに第一回と書いたとおり、続きます…)

手順

ライブラリインポート

今回スクリーンショットを撮るにあたって、Firebase Test Lab利用者向け?っぽいライブラリ「cloudtestingscreenshotter」を使用します。
Firebase公式に貼ってあるaarをプロジェクトに抱える形で参照します。

公式ドキュメントで書いてある通りにaarsディレクトリを作ってbuild.gradleを編集していくとなぜかうまいこと参照してくれなかったので、いつもやってる下記の方法を取りました。

  1. File -> New -> New Module -> Import .JAR/.AAR Package -> aarsにおいたライブラリのaarファイルを選択
  2. File -> Project Structure -> Dependencies から依存関係を追加

パーミッション付与

個人的につまったポイントなのですが、このライブラリを使用すると端末内のsdcard/screenshotsにスクショが保存されるはずが、全然保存されない…
公式のとおりにパーミッション付与してだめだったのですが、どうやらTargetSdkVersionをいくつにするかによって対応が変わってくるようです。
当初Targetを30に設定していたのですが、Android 10(APIレベル 29) から外部ストレージの扱いが変わった影響でいろいろとややこしくなってました。

うまいことやらないとFileNotFoundExceptionが発生してスクショが保存されません。
そして私はその例外を処理中でキャッチしていなかったのでずっと例外に気づきませんでした…

  • APIレベル28以下をTargetとする場合

公式に書いてある通り、マニフェストにパーミッションを記述すれば動きます。

<manifest ... >
   ...
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
   <!-- 公式にはINTERNETも書いてあるけど別になくても動く --/>
   ...
</manifest>
  • APIレベル29以上をTargetにする場合

applicationタグ内にandroid:requestLegacyExternalStorageを追加する必要があるようです。
公式には全く書いてないので気づきませんでした…

<application
    ....
    ....
    android:requestLegacyExternalStorage="true" >

細かいことはこちらを読んでみてください
Flutter で外部ストレージにアクセスするアプリを作るときは Android バージョンを考慮して『ひと手間』いるから注意してくれよな!

InstrumentedTest実装

今回はアプリプロジェクト作成時のデフォルトのプロジェクトファイルを使用します。画面遷移したかったのでFragmentをもっているBase Activityを選択。
テストもデフォルトで作成されるテストファイルに追記しました。

import androidx.navigation.findNavController
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.libraries.cloudtesting.screenshots.ScreenShotter
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {

    @get:Rule
    var scenarioRule = ActivityScenarioRule(MainActivity::class.java)

    @Before
    fun navigateToConfirm() {
        scenarioRule.scenario.onActivity { activity ->
            // 画面遷移しておく場合
            activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_FirstFragment_to_SecondFragment)
        }
    }

    @Test
    fun test() {
        scenarioRule.scenario.onActivity { activity ->
            ScreenShotter.takeScreenshot("secondFragment", activity)
        }
    }

   ....
   ....
}

あとはこの遷移とtakeScreenshotをベースに、各画面の処理を繰り返し書いていきます。

Test実行

AndroidStudio上で実行してもスクショは保存されますが、CIを構築することを考えてgradleコマンドで実行してみます。

./gradlew connectedAndroidTest

端末内のsdcard/screenshotsにスクショが保存されます。
上記の実装だとUnknownTestClass-unknownTestMethod-secondFragment-1.jpgになりました。
察するにテストクラス名やメソッド名がファイル名になるのですが、取得できていないようですね…
ここら辺は追々明らかにしたいところです。

感想

今回はアプリプロジェクト作成時にデフォルトでAndroid Architecture ComponentsのNavigationが設定されていたり、遷移時にIntentを渡す必要がなかったりするのですらっと書いてしまいましたが、実際に画面遷移も描くとなると結構ボリュームが出てきそうです。実際のアプリプロジェクトでもっと色々試してみようと思います。

本当は画像比較の自動化もひとつの記事内で取り上げたかったのですが、ボリュームがでてきたのと時間の都合で別記事に分けます!

次回は @somen440 さんです!

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

AndroidアプリにVisual Regression Test導入を目指す 第一回 Instrumented testで自動でスクショを撮る

この記事は Voicy Advent Calendar 2020 の17日目の記事です。
前日は @yamagenii さんの Goでmainが実行される様子を追ってみる でした。

はじめに

2日目に同じAndroidチームの ぬまさん(@moonum)がUnit testの内容を書いてくれたように、VoicyのAndroidチームでは「テストをしっかり書いていこう!」という空気感になりつつあります。
我々Androidチームは全員が8月9月にジョインしたばかりなのですが、いざプロジェクトファイルを覗いてみるとほとんどテストコードが無い…!!ということが発覚したのが発端です。
テストコードがほとんど無くともVoicyのアプリの品質が一定以上を維持されていたのは、ひとえにQAの方々の高品質なチェックによるものだったのです。
(事実、リリース前の動作確認では「本当に人力で見つけたのか?」と思うようなレアケースの不具合を検出していただくことも少なくありません)

どんなにテストをしっかり実装したところで最終的な人力チェックは確実に必要ではありますが、できるだけ自動化できるところは自動化して、手動でしか確認できないところに注力していただきたいところ。
そんなこんな思っているときに、先日ぬまさんにVisual Regression Testをちらっと教えてもらいました。
確かに自動でスクショ撮って修正前後で比較すればデグレチェックが一気に楽になる…!

というわけで今後VoicyのAndroidアプリでVisual Regression Testを導入するとなったときに先駆者となれるよう、お試しで動かしてみることにしました!

今回やること

「Visual Regression Test」を導入するにあたって、大きく工程を分けると

  • 画面ごとのスクリーンショットの自動化
  • 画像比較の自動化

に分かれます。今回は前半戦ということで、スクショをテストコード上で取得していきますよ〜

先駆者の方がいらっしゃったので、その記事を見つつ試しました。

『Finnovalley Developers Blog - MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)』
https://finnovalley.hatenablog.com/entry/2020/11/18/112425

基本的にはこちらの記事の内容を踏まえつつ、詰まったところをつけたそうと思います。

(ちなみにタイトルに第一回と書いたとおり、続きます…)

手順

ライブラリインポート

今回スクリーンショットを撮るにあたって、Firebase Test Lab利用者向け?っぽいライブラリ「cloudtestingscreenshotter」を使用します。
Firebase公式に貼ってあるaarをプロジェクトに抱える形で参照します。

公式ドキュメントで書いてある通りにaarsディレクトリを作ってbuild.gradleを編集していくとなぜかうまいこと参照してくれなかったので、いつもやってる下記の方法を取りました。

  1. File -> New -> New Module -> Import .JAR/.AAR Package -> aarsにおいたライブラリのaarファイルを選択
  2. File -> Project Structure -> Dependencies から依存関係を追加

パーミッション付与

個人的につまったポイントなのですが、このライブラリを使用すると端末内のsdcard/screenshotsにスクショが保存されるはずが、全然保存されない…
公式のとおりにパーミッション付与してだめだったのですが、どうやらTargetSdkVersionをいくつにするかによって対応が変わってくるようです。
当初Targetを30に設定していたのですが、29(Android 10)から外部ストレージの扱いが変わった影響でいろいろとややこしくなってました。

うまいことやらないとFileNotFoundExceptionが発生してスクショが保存されません。
そして私はその例外を処理中でキャッチしていなかったのでずっと例外に気づきませんでした…

  • APIレベル28以下をTargetとする場合

公式に書いてある通り、マニフェストにパーミッションを記述すれば動きます。

<manifest ... >
   ...
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
   <!-- 公式にはINTERNETも書いてあるけど別になくても動く --/>
   ...
</manifest>
  • APIレベル29以上をTargetにする場合

applicationタグ内にandroid:requestLegacyExternalStorageを追加する必要があるようです。
公式には全く書いてないので気づきませんでした…

<application
    ....
    ....
    android:requestLegacyExternalStorage="true" >

細かいことはこちらを読んでみてください
Flutter で外部ストレージにアクセスするアプリを作るときは Android バージョンを考慮して『ひと手間』いるから注意してくれよな!

InstrumentedTest実装

今回はアプリプロジェクト作成時のデフォルトのプロジェクトファイルを使用します。画面遷移したかったのでFragmentをもっているBase Activityを選択。
テストもデフォルトで作成されるテストファイルに追記しました。

import androidx.navigation.findNavController
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.libraries.cloudtesting.screenshots.ScreenShotter
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {

    @get:Rule
    var scenarioRule = ActivityScenarioRule(MainActivity::class.java)

    @Before
    fun navigateToConfirm() {
        scenarioRule.scenario.onActivity { activity ->
            // 画面遷移しておく場合
            activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_FirstFragment_to_SecondFragment)
        }
    }

    @Test
    fun test() {
        scenarioRule.scenario.onActivity { activity ->
            ScreenShotter.takeScreenshot("secondFragment", activity)
        }
    }

   ....
   ....
}

あとはこの遷移とtakeScreenshotをベースに、各画面の処理を繰り返し書いていきます。

Test実行

AndroidStudio上で実行してもスクショは保存されますが、CIを構築することを考えてgradleコマンドで実行してみます。

./gradlew connectedAndroidTest

端末内のsdcard/screenshotsにスクショが保存されます。
上記の実装だとUnknownTestClass-unknownTestMethod-secondFragment-1.jpgになりました。
察するにテストクラス名やメソッド名がファイル名になるのですが、取得できていないようですね…
ここら辺は追々明らかにしたいところです。

感想

今回はアプリプロジェクト作成時にデフォルトでAndroid Architecture ComponentsのNavigationが設定されていたり、遷移時にIntentを渡す必要がなかったりするのですらっと書いてしまいましたが、実際に画面遷移も描くとなると結構ボリュームが出てきそうです。実際のアプリプロジェクトでもっと色々試してみようと思います。

本当は画像比較の自動化もひとつの記事内で取り上げたかったのですが、ボリュームがでてきたのと時間の都合で別記事に分けます!

次回は @somen440 さんです!

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

GooglePlay アプリ公開

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