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

1. Android Studioインストール(Kotlin)

はじめに

DreamHanksのMOONです。

今回からKotlin言語を使用してAndroidプログラミングについて見てみます。

本来AndroidはJava言語を使って開発されていましたけど、最近はKotlinを使って開発することが多くなっています。
今回はKotlinを使用してみます。

開発ツールをインストール

 Androidを開発するためのAndroid Studioをインストールしてみます。
       https://developer.android.com/studio?hl=jp
 この公式サイトのリンクでアンドロイドスタジオインストールをします。

Android Studioプロジェクト生成 

  インストール後、最初にアンドロイドスタジオを実行すると以下の画面が表示されます。
  一番上の「Start a new Android Studio project」を押してプロジェクトを作成します。
  1.JPG
  プロジェクトテンプレートの中からempty Activityを選択します。

2.JPG
3.JPG
  「Name」にはご希望のプロジェクト名を作成。
  「Package name」には自動的にプロジェクトの名の以前ルートが生成されますが、
 修正したい場合には修正します。
  「Save location」も自動的に生成されます。
  「Language」には今回はKotlinを使用していきたいと思いますのでKotlinを選択します。
  「Minimum SDK」は最小のバージョンを選択します。15バージョンを選択する場合、
 以下のメッセージのようにほとんどのデバイスで動作可能です。
 上の作業が終わった場合、finishボタンをクリックすると、プロジェクト生成が完了されます。

プロジェクトの基本要素確認

Activityファイル
5.JPG

MainActivity.kt
package com.example.practiceapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

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

  Android Activityは、画面に表示されるUIを構成するための一番基本となる要素です。 
  Androidアプリは画面にUIを表示するため、最小1つのActivityを持つ必要があり、
  アプリ実行時に指定されたActivityを実行してユーザーにUIを表示させます。

レイアウトのファイル
4.JPG

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">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

  「res → layout」のパースに画面に表示されるxmlファイルがあります。
  layoutのxmlファイルもアプリに画面を表示されるための一番基本となる要素です。

終わりに

  ここまでがアンドロイドスタジオインストールとプロジェクト生成しました。
  次回は画面にボタンとテキストを追加し、イベントを設定してみます。

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

1. 【Android/Kotlin】Android Studioインストール

はじめに

DreamHanksのMOONです。

今回からKotlin言語を使用してAndroidプログラミングについて見てみます。

本来AndroidはJava言語を使って開発されていましたけど、最近はKotlinを使って開発することが多くなっています。
今回はKotlinを使用してみます。

開発ツールをインストール

 Androidを開発するためのAndroid Studioをインストールしてみます。
       https://developer.android.com/studio?hl=jp
 この公式サイトのリンクでアンドロイドスタジオインストールをします。

Android Studioプロジェクト生成 

  インストール後、最初にアンドロイドスタジオを実行すると以下の画面が表示されます。
  一番上の「Start a new Android Studio project」を押してプロジェクトを作成します。
  1.JPG
  プロジェクトテンプレートの中からempty Activityを選択します。

2.JPG
3.JPG
  「Name」にはご希望のプロジェクト名を作成。
  「Package name」には自動的にプロジェクトの名の以前ルートが生成されますが、
 修正したい場合には修正します。
  「Save location」も自動的に生成されます。
  「Language」には今回はKotlinを使用していきたいと思いますのでKotlinを選択します。
  「Minimum SDK」は最小のバージョンを選択します。15バージョンを選択する場合、
 以下のメッセージのようにほとんどのデバイスで動作可能です。
 上の作業が終わった場合、finishボタンをクリックすると、プロジェクト生成が完了されます。

プロジェクトの基本要素確認

Activityファイル
5.JPG

MainActivity.kt
package com.example.practiceapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

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

  Android Activityは、画面に表示されるUIを構成するための一番基本となる要素です。 
  Androidアプリは画面にUIを表示するため、最小1つのActivityを持つ必要があり、
  アプリ実行時に指定されたActivityを実行してユーザーにUIを表示させます。

レイアウトのファイル
4.JPG

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">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

  「res → layout」のパースに画面に表示されるxmlファイルがあります。
  layoutのxmlファイルもアプリに画面を表示されるための一番基本となる要素です。

終わりに

  ここまでがアンドロイドスタジオインストールとプロジェクト生成しました。
  次回は画面にボタンとテキストを追加し、イベントを設定してみます。

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

Jetpack Compose (アルファ版) ハンズオン

Jetpack Compose がアルファリリースされたので遅まきながら触ってみました。

色々と新しいワードやコーディングばかりで学習コストがかかりそうかも...?
でも Kotlin で書けるのは嬉しかったりします?

ひとまず簡単に ViewModel なんかを使いながら、リストやツールバーの表示を実装してみます。

Environment

今回使用した環境は以下になります。

Tools version
Android Studio 4.2 Canary 9
Kotlin 1.4.0
Jetpack Compose 1.0.0-alpha02

Get Started

まずはプロジェクトを作成します。
Canary 版には Empty Compose Activity のテンプレートがあるので開いてみましょう。

empty_compose_activity.png

作成すると自動で以下の様な Activity が出来上がります。
これだけでも結構色々出てきて困惑しますね。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    JetpackComposeCodelabTheme {
        Greeting("Android")
    }
}

setContent

Activity と ViewGroup の拡張関数として定義されている、レイアウトを定義していくスコープ

Wrapper.kt
fun ComponentActivity.setContent(
    // Note: Recomposer.current() is the default here since all Activity view trees are hosted
    // on the main thread.
    recomposer: Recomposer = Recomposer.current(),
    content: @Composable () -> Unit
): Composition {
    FrameManager.ensureStarted()
    val composeView: AndroidOwner = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? AndroidOwner
        ?: AndroidOwner(this, this, this).also {
            setContentView(it.view, DefaultLayoutParams)
        }
    return doSetContent(composeView, recomposer, null, content)
}

content ブロック内に UI を記述する事で、ルートの View (AndroidOwner) にレイアウトしていきます。
@Composable で定義された関数を呼び出す事で、レイアウトの定義を関数に切り出す事も可能です。

CompositionsetContentdispose を持つインタフェースで、setContent 内で宣言した UI をレイアウトする役割があります。
内部の処理は CompositionImpl がやってくれるので戻り値の Composition に対して何かする必要はありません。
dispose で空のレイアウトを上書きできる様ですが使いみちが思いつきません?

Theme

JetpackComposeCodelabTheme は端末のライトモード/ダークモードの設定を元に MaterialTheme のスタイルをセットアップしてくれます。
他にフォントやカラーの定義もしてくれています。

こういったテーマを定義しておくと View のスタイルに適応できる様です。
MaterialTheme を指定しておくと Text 等でマテリアルデザインのスタイルを指定できる様になります。

Theme.kt
@Composable
fun JetpackComposeCodelabTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes,
        content = content
    )
}
Type.kt
val typography = Typography(
    body1 = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    )
    /* Other default text styles to override
    button = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.W500,
        fontSize = 14.sp
    ),
    caption = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp
    )
    */
)
Shape.kt
val shapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(4.dp),
    large = RoundedCornerShape(0.dp)
)

これからは colors や dimens なんかのリソースに定義していたものもコード管理していく事になるんでしょうか??

Surface

Jetpack Compose Surface でググると Microsoft の Surface Duo が出てきます。
(ググラビリティ。。。)

@Composable
fun Surface(
    modifier: Modifier = Modifier,
    shape: Shape = RectangleShape,
    color: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(color),
    border: BorderStroke? = null,
    elevation: Dp = 0.dp,
    content: @Composable () -> Unit
) {
    val elevationPx = with(DensityAmbient.current) { elevation.toPx() }
    SurfaceLayout(
        modifier.drawLayer(shadowElevation = elevationPx, shape = shape)
            .zIndex(elevation.value)
            .then(if (border != null) Modifier.border(border, shape) else Modifier)
            .background(
                color = getBackgroundColorForElevation(color, elevation),
                shape = shape
            )
            .clip(shape)
    ) {
        Providers(ContentColorAmbient provides contentColor, children = content)
    }
}

マテリアルデザインなレイアウトを作るための Layout コンポーネントです。
ボーダーやエレベーションを指定できますね。

SurfaceLayout は左上にコンポーネントを配置する Layout の様です。
(FrameLayout かな??)

Text

Text を表示する UI コンポーネントです。 TextView ですね。
引数に color や style を指定する事で今までの xml 上の textColor 等が設定できます。

Modifier

サンプルには出て来ていませんでしたが、Modifier について触れておきます。

Modifier は UI コンポーネントに対して Padding を設定したり、ドロー系の色々ができたり、クリックのイベント処理を指定したりできるインタフェースです。
今まで xml で指定していたレイアウト系の設定 (width, gravity, ... ) は Modifier で指定する事になりそうですね。

MainActivity.kt
Text(
    text = "Hello World!",
    modifier = Modifier
          .fillMaxWidth()
          .fillMaxHeight()
          .padding(10.dp)
          .clickable { }
)

少し話がずれますが、すごいと思ったのが以下の記事の内容です。

Jetpack ComposeのConstraintLayoutなどがどのようにModifierクラスだけで様々なレイアウトを処理するか - Qiita

例えば ConstraintLayout での constraint も Modifier で指定しますが、普通の Layout スコープでは見えません。(使えたら困りますもんね。。。)

ちゃんと配慮されていてかっこいいなと思いました?

@ Preview

Android Studio で宣言した UI をプレビューするための関数を定義できる。
@Composable で定義した物とは別にプレビュー用の関数を作成する必要があります。

List 表示と State による画面更新

ここから、リスト表示とアイテムがクリックされた時に中のテキストを変更するまでを実装していきます。
まずはリストの表示から。

LazyFor

Jetpack Compose でリスト表示する時は LazyFor 関数を使います。

関数 向き
LazyColumnFor
LazyRowFor

text_list.png

このような縦方向のリストを表示する場合は

MainActivity.kt
LazyColumnFor(items = (0 until 100).map { it.toString() },
              contentPadding = InnerPadding(8.dp)) {
    Text(text = it)
}

の様に記述できます。

↓の様にもできますが、LazyFor を使った方が
ネストが少なかったり、コンテンツ同士の Padding が指定できたりしていいですね。

Column は UI コンポーネントを上から順に縦に並べていきます。
(LinearLayout: orientation = "vertical" みたいな感じでしょうか。)

MainActivity.kt
Column {
    (0 until 100).forEach {
        Text(it.toString())
    }
}

アイテム間のスペースを空ける場合は、Text に Modifier を設定しましょう。

State で画面更新

表示されたリストのアイテムがタップされた時に中のテキストを変更してみます。
視覚的に分かりやすい様に少しレイアウトを変更します。

MainActivity.kt
val items = (0 until 100).map { it.toString }.toMutableList()
LazyColumnForIndexed(items = items) { index, item ->
    Card(modifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
        items[index] = "Clicked!"
    }) {
        Text(text = item,
             textAlign = TextAlign.Start,
             modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
    }
}

Modifier.clickable がクリックイベントの処理になります。
ただし、このままでは思った様に画面が更新されません。

list_mistake.gif

A stateless composable is a composable that cannot directly change any state.

Jetpack Compose での UI は基本的にはステートレスです。
そのためプロパティが変更されても再描画されず、スクロールして描画され直すまで更新されません。

クリックのタイミングで更新するにはプロパティがステートを持つ必要があります。
ステートを持たせるには、今回はリストなので toMutableStateList() が使えます。

MainActivity.kt
val items = (0 until 100).map { it.toString }.toMutableStateList()  // <-
LazyColumnForIndexed(items = items) { index, item ->
    Card(modifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
        items[index] = "Clicked!"
    }) {
        Text(text = item,
             textAlign = TextAlign.Start,
             modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
    }
}

こうすることでステートを持ったリストができるので

list_updated.gif

と、クリックと同時に更新ができました。

カウントアップのサンプル等で見かけた state {} は alpha 版では deprecated なので remember { mutableStateOf() } を使いましょうとの事です。

ViewModel + LiveData で画面更新

今までの様に ViewModel + LiveData でデータソースの管理と通知による画面更新をしていきます。

LiveData を使う場合には拡張関数の observeAsState() が value にステートを持たせてくれるので、
使うために livedata 用のサブライブラリをインポートします。

build.gradle
implementation "androidx.compose.runtime:runtime-livedata:1.0.0-alpha02"

後はいつもの様に ViewModel を作り LiveData を持たせます。

MainViewModel.kt
class MainViewModel: ViewModel() {
    private val _items: MutableLiveData<MutableList<String>> = MutableLiveData()
    val items: LiveData<List<String>> = _items.map { it.toList() }

    init {
        _items.postValue((0 until 100).map { it.toString() }.toMutableList())
    }

    fun click(index: Int) {
        _items.value?.let {
            it[index] = "Clicked!"
            _items.postValue(it)
        }
    }
}

いつもならこの items を observe して流れてきたデータに対して色々していましたが、
Composable 関数で使う時は observeAsState でステートを持った value を扱います。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                Surface(color = MaterialTheme.colors.background) {
                    // observeAsState でステートを持たせた value を渡す
                    Greeting(viewModel.items.observeAsState(listOf()).value)
                }
            }
        }
    }

    @Composable
    private fun Greeting(items: List<String>) {
        LazyColumnForIndexed(items, contentPadding = InnerPadding(8.dp)) { index, item ->
            val cardModifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
                viewModel.click(index)
            }
            Card(modifier = cardModifier) {
                Text(text = item,
                     textAlign = TextAlign.Start,
                     modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
            }
        }
    }
}

こうする事で今までの ViewModel から LiveData のデータの流れが出来ました。

ちょっと疑問なんですけど、Composable 関数はトップレベルに書くのがベターなんでしょうか?
Screen 用のファイルを作成してそのトップレベルに書いてあるサンプルが多いので... (JetNews とか)

そうすると View -> ViewModel にイベントを上げる時に、どうするのがいいんでしょうね?

  • Composable 関数に ViewModel を渡す?
  • onClick みたいな関数リテラルを渡す?

今回は Activity 内に Composable 関数を書いてみましたが再利用性がないなぁ、と。
これはこれで一個の画面構成としては正しい気も。
Codelab では ViewModel を渡してたな...

色々試してみて最適解を見つけていきたいですね。
こうやってみましたみたいな意見があれば教えていただけると嬉しいです。

ツールバー (AppBar)

画面トップのツールバーを表示してメニューボタンを実装してみます。

list_toolbar.gif

ツールバーを表示する場合は TopAppBar を使います。
メニューボタンには Android Studio から VectorImage を drawable に吐き出してアイコンにします。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                Scaffold(topBar = { AppBar(title = "Codelab") }) {
                    Greeting(viewModel.items.observeAsState(listOf()).value)
                }
            }
        }
    }

    @Composable
    private fun AppBar(title: String) {
        TopAppBar(
            title = { Text(text = title) },
            actions = {
                IconButton(onClick = { viewMode.refresh() }) {
                    Image(vectorResource(id = R.drawable.ic_refresh))
                }
            }
        )
    }
}

TopAppBar

ツールバーを表示するのに使うコンポーネントです。

AppBar.kt
@Composable
fun TopAppBar(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    navigationIcon: @Composable (() -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {},
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    elevation: Dp = TopAppBarElevation
) {
    AppBar(backgroundColor, contentColor, elevation, RectangleShape, modifier) {
        val emphasisLevels = EmphasisAmbient.current
        if (navigationIcon == null) {
            Spacer(TitleInsetWithoutIcon)
        } else {
            Row(TitleIconModifier, verticalGravity = ContentGravity.CenterVertically) {
                ProvideEmphasis(emphasisLevels.high, navigationIcon)
            }
        }

        Row(
            Modifier.fillMaxHeight().weight(1f),
            verticalGravity = ContentGravity.CenterVertically
        ) {
            ProvideTextStyle(value = MaterialTheme.typography.h6) {
                ProvideEmphasis(emphasisLevels.high, title)
            }
        }

        ProvideEmphasis(emphasisLevels.medium) {
            Row(
                Modifier.fillMaxHeight(),
                horizontalArrangement = Arrangement.End,
                verticalGravity = ContentGravity.CenterVertically,
                children = actions
            )
        }
    }
}

title にも Composable を渡すので柔軟にレイアウトを作れそうです。

actions に渡した Composable が右側のメニューのレイアウトになります。
今回はアイコンを表示するので IconButton を使っています。

プレビュー版から AppBarIcon は削除され IconButton を使うように変わっています。

JetNews では navigationIcon に Drawer を実装していました。

TopAppBar の他に BottomAppBar もあり、BottomNavigation みたいな事もできそうです?

Image

Image == ImageView ですね。

TopAppBar の actions には Composable を渡す事になるので、アイコンを表示するだけなら Image を渡せば問題ないです。

ただし drawable から読み込む際は image か vector かを意識してロードしないと、間違えると NPE になります?

Scaffold

  • scaffold == 足場

AppBar を使う場合はそのまま TopAppBar を追加すると、バーの下にコンテンツが潜り込んでしまいます。
Scaffold で UI を定義しておくと引数に AppBar を渡せて、マテリアルデザインないい感じのレイアウトにしてくれます。

Scaffold のトップのレイアウトは Surface でレイアウトされていました。

Conclusion

以上、今回試してみたハンズオンのリポジトリはこちらになります?

ダイアログ表示したりとかもしてみました。
個人的にはダイアログ表示のハンドリングの仕方がもう少しどうにかならないか悩んでます?

GitHub - tick-taku/Jetpack_Compose_Codelab: Sample for Jetpack Compose.

間違いやご指摘等ありましたらコメントいただけると幸いです。

alpha 版なのでばんばん変更されるかもしれませんし、まだ参考が少ないですね。?
でも xml と行き来したりがなくなって管理しやすかったり、新しい書き方ができて楽しいです?

関数リテラルをぽんぽん渡す事になるので、どうしたら可読性が上がるか考えないと。。。

一番の問題はまだ Navigation Component に対応してないみたいなので、Fragment をどう実装するかですね。

今のところ xml にも良さがあって勝手も分かっているので比較はあまり出来ませんでしたが、今後たくさん触って慣れていきたいと思います。
Gradle も含めほぼ全て Kotlin で Android アプリが書けるのはとても嬉しいです!

参考

Layouts in Jetpack Compose  |  Android デベロッパー  |  Android Developers

[Published] Jetpack Compose basics

Using State in Jetpack Compose  |  Android デベロッパー  |  Android Developers

compose-samples/JetNews at master · android/compose-samples · GitHub

android - How to create recycler view in Compose Jetpack? - Stack Overflow

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

セキュリティの尻隠して頭隠さず...?Androidに実装されている「プライベートDNS」を利用しよう

本日のお題。
セキュリティの穴、DNSを何とかしましょう!

自宅用DNSサーバーを構築してからDNSがマイブームな今日この頃、プライベートDNSについての記事に出会いました。
以前から有効にはしていましたが、どんな動作をしているのか気になって調査してみました。

プライベートDNSとは?

Android 9 (Pie)以降のスマートフォンに実装されている機能で、以下のような感じでアクセスできると思います。

  • 設定その他の接続プライベートDNS
  • 設定ネットワークとインターネットプライベートDNS
  • 設定ネットワークとインターネット詳細設定プライベートDNS

取り敢えずはプライベートDNSを設定を選択してdns.googleone.one.one.oneなどと打ち込むと設定が完了します。
ほぼ同様の内容ですが、Google Public DNSに詳しい説明が記載されています。
なお、自動に設定した場合は、設定中のDNSサーバーが本機能に対応している場合に有効となります。

どういう機能?

Android Developers Blogによりますと...

Like HTTPS, DNS over TLS uses the TLS protocol to establish a secure channel to the server. Once the secure channel is established, DNS queries and responses can't be read or modified by anyone else who might be monitoring the connection. (The secure channel only applies to DNS, so it can't protect users from other kinds of security and privacy violations.)

DNSの問い合わせを安全にしようってことらしいです。

「DNSってなーに?」って人は、上記記事を参照するか、ググってください。

DNSが策定された1980年当時、インターネットは今より小規模で単純だったのでDNSに機密性は求められていなかったのですが、近年の著しい発達に伴いセキュアなプロトコルが必要になったため偉い人がRFC 7858で策定したのがDNS over TLS(以下DoT)で、そのDoTを適用してDNSの問い合わせを行うのがプライベートDNSの機能です。

DNS over TLS

DoTの仕組みはHTTPSと似たような感じとなっており、DNSサーバーとクライアント間で確立したセキュアなチャンネルを用いてDNSの通信を行い、第三者が閲覧・改竄できないようにしています。

あたりまえですが、DoTで保護されるのはDNSの通信のみで他の通信は暗号化されませんのでHTTPなんかで接続すれば内容は丸見えになってしまいます。
加えて、DoTが保証するのはクライアント⇔DNSサーバー間の信頼性と機密性のみです。
こちらが詳しいですが、接続先のDNSサーバー自体が不正な場合などの安全性や、ホップ先の機密性は担保されていません。
DoTHTTPSと同様にデジタル証明書で管理されますが、デジタル証明書は偽でないことを証明するだけで接続先が安全であるか否かは関知しません。
いたちごっこなのはセキュリティの性ですが、悪意のある第三者だけでなくフィルタリングなど監視活動の大半もDNSクエリを傍受して行われますし、適用しておくべきプロトコルだと思います。

もっと知りたい方はJPNICさんで公開されている資料「DoH/DoT入門」が分かり易いのでおすすめです。

実際の通信を見てみよう

DNSがどのようにやり取りされているのかWiresharkというパケット監視ソフトで調査してみました。
Androidのパケット監視方法は色々あるようですが、今回は手軽に以下のような構成で通信を監視しました。
プレゼンテーション1.png
こうやってパケットを監視していると、公衆Wi-Fiなどが如何に危険かが身に沁みます。
一般人でも超手軽にパケットをのぞき見できるわけですから、平文通信なんて以ての外ですね。
未だにHTTPしか対応していない企業HPなども見かけますが、早急に対応してほしいものです。
無知なユーザー(決して貶める意ではありません)を脅威から保護するために最善の努力を行うのは、私たちホスト側の責務だと思います。

今回は調査目的で自分の通信を覗いていますが、くれぐれも他人の通信を覗いたり悪用することのないようにお願いします。

プライベートDNSが無効のとき

PCなどのDNSクエリストリームも同様の動作となっていると思います。

プレゼンテーション3.png
スマホ(192.168.137.149)からGoogle Public DNS(8.8.8.8)にqueryが投げられ、Google Public DNSからスマホにquery responseとしてIPアドレス(黒塗り部分)が返ってきています。
問い合わせ(クエリ)と応答(レスポンス)で一セットになる標準的なDNSのやり取りで、DoTでも秘匿されこそすれ同様の通信が行われます。
Google Public DNSをPCのDNSサーバーに設定しているので8.8.8.8に問い合わせていますが、環境によってはISPのDNSサーバー等に投げていると思います。

  • クエリとレスポンスが平文でやり取りされており、第三者から丸見え
  • 一回の問い合わせで発生する通信は上りと下りの一往復

DNSはスピード重視ですのでプロトコルにはUDPが用いられています。
ステートレスなプロトコルですのでハンドシェイクなどは行われず、シンプルな通信が可能です。
仮にどこかでパケットが消えても再問合せすれば良いだけですので合理的だと思いますが、パケットが丸見えですので安全性もへったくれもありません。
特に無線通信ではプライバシーが侵害されたり、改竄されたりする可能性が高まります。
ちなみに、これらのDNSに関する通信の標準ポートは53です。

dns.googleを設定したとき

Google Public DNSAndroid 9以降向けに紹介されている方法です。
TSLという暗号化方式を利用するにあたって、まずハンドシェイクという手順が行われます。

ハンドシェイク

パケットに付したコメントはさほど正確でないのであくまでイメージだと思ってください。
プレゼンテーション4.png
まず、プライベートDNSに入力できるのはホスト名のみなので、dns.googleを解決する必要があります。
もともとGoogle Public DNS (8.8.8.8)がDNSサーバーなので分かりにくいですが、グレー部分より上の8.8.8.8はネットワークに設定されていたDNSサーバーで、下はスマホに設定されたdns.googleを解決した結果のDNSサーバーです。
この部分はセキュアではない、伝統的なDNSのやり取りです。

dns.googleが解決されたらTLSのハンドシェイクが始まります。
詳しい説明は省きますが、DNSサーバーとクライアントの間で公開鍵乱数をやり取りして共通鍵を作成し、以後はその共通鍵を用いて暗号化を行い通信します。

TCPTLSにそれほど詳しくないのですが、TCP3way ハンドシェイクといって3回パケットをやり取りしなければ始まりません。
また、TLSは基本的にClient Helloというパケットが始まりの合図で、それはTCPの接続が確立した後に行われるはずです。
ここでは3way ハンドシェイクClient Helloが同時に行われているように見えますが実際どうなっているのでしょうか。
詳しい方がいましたらご教授下さい。

DNSクエリストリーム

ハンドシェイクの後は以下のような通信が行われます。
通信内容は暗号化されているので付したコメントは想定ですが、仕組み上これで正しいと思われます。
プレゼンテーション5.png
通信の内容がTLSv1.2で暗号化されているため、内容はApplication Dataと表示されています。
これで第三者はこれらの通信を傍受しても内容を確認することはできず、それぞれのパケットはハンドシェイク時の相手から送られてきていることが証明されていますので、一定の信頼性と機密性が保証されています。

問い合わせ(クエリ)と応答(レスポンス)がペアで行われている基本的な点は伝統的なDNSと変わりませんが、使用しているプロトコルが投げ捨てのUDPから、確認付きのTCPに代わったためACKパケットが増えています。
このACKパケットは必ず送られるものでもなく、効率化のためにバッファ分をまとめて送信することになっているので若干不規則な並びになっています。

TCP Keep-Aliveというパケットが目を引きますが、これはTCPは一度接続が切れると再びハンドシェイクからやり直さなくてはならないため、一定期間通信が行われなかった時に切断されないよう送られるパケットです。

  • 通信開始時にハンドシェイクという手続きが必要
  • クエリとレスポンスが暗号化されており、第三者が確認できない
  • 通信は、一回の問い合わせで発生する上りと下り + TCPのパケット

伝統的なDNSに比べて複雑になっているものの、TCPTLSといった下層プロトコルに伝統的なDNSを載せているだけなので理解はし易いです。
一度ハンドシェイクが済んでしまえば、レイテンシーもそこまで増大せず安全に利用できるよくできた実装だと思います。

弱点

すこし話はずれますがDoTの弱点にも触れておきたいと思います。

まず、通信環境の悪いところなどではTCPのコネクションが頻繁に切断され、ハンドシェイクが大量に行われ、それもなかなか上手くいかず…という感じでIPアドレスが取得できず接続エラーになってしまうことがあります。
自分も登山中に経験したことがあり、プライベートDNS無効にすることで普通に接続できるようになりました。

また、個々の負荷増大は軽微でもDNSサーバーにとっては大きな負荷になるのかと思います。
基本的にACKでパケットが1.5倍ですからネットワークトラフィックも増大しますし、暗号化と復号化の処理も負荷になりそうです。
それでも(無料で)安定したPublic DNSを提供して下さっているのですから、関係各社様には頭が上がりません。

DoTで保護されるのはDNSサーバーとクライアント間の通信のみであることは前述のとおりですが、ハンドシェイク前のホスト名解決が伝統的なDNSである点が気になりました。
dns.googleに対して偽のIPアドレスを解決すれば以後の通信が不正になるのではと思いましたが問題ないのでしょうか?
とはいえ、なりすましやスコープ外の危険性はDoTに固有のものではありませんので、導入を否定する要因にはなりません。

自動を設定したとき

Google Public DNSによると、自動に設定した場合は、まず853ポートへの接続を試行し、成功したらDoTで、失敗したら53ポートへ伝統的なDNSで問い合わせを行うようです。

当方の環境ではdns.googleを明示した時と同じでした。
google_public_dns_auto.png
DNSサーバーが既定のままだったり、コネクションがうまくいかなかったりすると伝統的なDNSで問い合わせが行われると思われます。

一度、自動に切り替えた後TCPの接続に失敗して伝統的なDNSでやり取りしていた時がありました。
手動で再接続したらDoTに切り替わりましたが、どうやらDoTに執着するよりも接続性を優先する実装のようです。

one.one.one.oneを明示した時

自称最速DNSサーバー、Cloudflareの1.1.1.1です。
前述の通り設定できるのはホスト名のみなのでone.one.one.oneと設定します。

cloudflare_auto.png

こちらもDoTで秘匿された通信ができています。

当方ではベンチマークの結果が良かったGoogle Public DNSを採用しています。
他にも速いところはあるのですが安全性などの面も含めての決定です。

OpenDNSを明示した場合

国内にサーバーがあり優秀な部類に入ると思われるOpenDNSですが、DoTには対応してなさそうです。
ホスト名をnslookupで検索してプライベートDNSに明示してみましたが、853ポートへアクセスできませんでした。
パケットを監視しているとエラーのパケットが返ってきているのが見えますが、スマホからはIPアドレスが取得できない状態が見えるだけです。
対応していない(または存在していない)ホスト名を打ち込むと通信ができなくなるのでよく確認する必要がありますね。

おまけ(Windows)

WindowsではDNS over HTTPS(以下DoH)という技術でDNS通信を暗号化しようとしています。
これはHTTPS443ポートを用いてDNSを行うもので、UDPパケットTCPパケットの両方が用いられます。
2020年9月10日時点ではOSレベルの標準実装は行われていませんが、ChromeなどのブラウザはDoHでDNSの問い合わせを行っています。
プレゼンテーション6.png
このように伝統的なDNSの問い合わせとDoHが混在していて、ブラウザから投げられるDoHUDPプロトコルを利用しています。
DoHでもホスト名解決から始まるのは変わらないんですね。

ちなみにWindows insider Program (Dev Channel)に登録していてBuild 19628以降がインストールされていれば、レジストリを弄ることでOSレベルでもDoHを適用することができます。
プレゼンテーション7.png
基本的に全ての通信がDoHで行われているのが確認できます。
nslookupはDNSサーバーを明示している扱いなのでDoHの適用から外れているのだと思われます。
ブラウザレベルでの実装と違ってTCPTLSv1.2が用いられているのが面白いですね。
Dev Channelはだいぶ不安定になりますが気になる方は是非自己責任でどうぞ。
設定方法は公式ブログを参照してください。

所感

悪意ある第三者は虎視眈々と我々の通信を狙っています。
コロナ禍で社内だけだったビジネス関係の通信環境も多種多様になったことと思いますが、セキュリティ対策まで手が回りにくいのも実情かと思います。
Wi-Fiやファイアウォール、VPNなど、セキュリティは多角的にアプローチする必要がありますが、全ての通信の起点といっても過言ではないDNSを意識することはあまりありませんでした。
手軽に、どこでも使えるスマートフォンだからこそ、少しでも安全な通信ができるよう適用しておきたい「プライベートDNS」、皆様もこの機会に是非設定してみてはいかがでしょうか。

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

【React Native】TextInputにselectionColorを設定するとAndroidで文字が見えなくなる

前提

  • React Native 0.62.2

問題

  • カーソルの色をカスタマイズしようと、TextInputにselectionColorプロパティを設定すると、Androidでは透過が起きず、文字が見えなくなる image.png

解決方法

  • 以下のように半透明の色を直接指定する
selectionColor='rgba(0, 0, 0, 0.5)'

image.png

参考文献

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

Android, Kotolin: CSVの読み書き

  • Minimum SDK: API 23
  • Android Studio 4.0.1
  • Kotlin 1.4.0
  • opencsv Version: 5.2

Android, KotlinでCSVの読み書きを、opencsvを使って実装します。
CSVの操作を説明するために、StringReader, StringWriterを使って実装していますので、必要に応じてFileReader, FileWriter, InputStreamReader, OutputStreamWriterに置き換えて実装してください。

SDカードへのファイル読み書きはこちらを参照ください。→ Android: SDカード上のファイルへの読み書き

準備

opencsvライブラリ取り込み

build.gradle(app)
dependencies {
    implementation 'com.opencsv:opencsv:5.2'
}

配列を使った方法

書き込み

実装
        val strWriter = StringWriter()
        val csvWriter = CSVWriter(strWriter)
        csvWriter.writeNext(arrayOf("三幸太郎", "45", "男"))
        csvWriter.writeNext(arrayOf("三幸次郎", "44", "男"))
        csvWriter.writeNext(arrayOf("三幸花子", "35", "女"))
        println(strWriter.toString())
出力
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"

読み込み

読み込み元は、先ほど作成したCSVテキスト(strWriter)

実装
        val strReader = StringReader(strWriter.toString())
        val csvIter = CSVIterator(CSVReader(strReader))
        for (row in csvIter) {
            for (col in row) {
                print(col)
            }
        }

オブジェクト(Bean)を使った方法

データ定義

初期値はいるみたいです。

CSVヘッダー無し
data class User(
    @CsvBindByPosition(position = 0)
    val name: String = "",

    @CsvBindByPosition(position = 1)
    val age: Int = 0,

    @CsvBindByPosition(position = 2)
    val gender: String = ""
)
CSVヘッダーあり
data class User(
    @CsvBindByName(column = "名前", required = true)
    val name: String = "",

    @CsvBindByName(column = "年齢", required = true)
    val age: Int = 0,

    @CsvBindByName(column = "性別", required = true)
    val gender: String = ""
)

書き込み

実装
        val strWriter = StringWriter()
        val beanWriter = StatefulBeanToCsvBuilder<User>(strWriter).build()
        beanWriter.write(User("三幸太郎", 45, "男"))
        beanWriter.write(User("三幸次郎", 44, "男"))
        beanWriter.write(User("三幸花子", 35, "女"))
        println(strWriter.toString())
出力(ヘッダー無し)
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"
出力(ヘッダーあり)
"名前","年齢","性別"
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"

読み込み

読み込み元は、先ほど作成したCSVテキスト(strWriter)

実装
        val strReader = StringReader(strWriter.toString())
        val beanList = CsvToBeanBuilder<User>(strReader)
            .withType(User::class.java)
            .withIgnoreLeadingWhiteSpace(true)
            .build()
            .parse()
        for (user in beanList) {
            println(String.format("%s, %d, %s", user.name, user.age, user.gender))
        }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android, Kotlin: CSVの読み書き

  • Minimum SDK: API 23
  • Android Studio 4.0.1
  • Kotlin 1.4.0
  • opencsv Version: 5.2

Android, KotlinでCSVの読み書きを、opencsvを使って実装します。
CSVの操作を説明するために、StringReader, StringWriterを使って実装していますので、必要に応じてFileReader, FileWriter, InputStreamReader, OutputStreamWriterに置き換えて実装してください。

SDカードへのファイル読み書きはこちらを参照ください。→ Android: SDカード上のファイルへの読み書き

準備

opencsvライブラリ取り込み

build.gradle(app)
dependencies {
    implementation 'com.opencsv:opencsv:5.2'
}

配列を使った方法

書き込み

実装
        val strWriter = StringWriter()
        val csvWriter = CSVWriter(strWriter)
        csvWriter.writeNext(arrayOf("三幸太郎", "45", "男"))
        csvWriter.writeNext(arrayOf("三幸次郎", "44", "男"))
        csvWriter.writeNext(arrayOf("三幸花子", "35", "女"))
        println(strWriter.toString())
出力
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"

読み込み

読み込み元は、先ほど作成したCSVテキスト(strWriter)

実装
        val strReader = StringReader(strWriter.toString())
        val csvIter = CSVIterator(CSVReader(strReader))
        for (row in csvIter) {
            for (col in row) {
                print(col)
            }
        }

オブジェクト(Bean)を使った方法

データ定義

初期値はいるみたいです。

CSVヘッダー無し
data class User(
    @CsvBindByPosition(position = 0)
    val name: String = "",

    @CsvBindByPosition(position = 1)
    val age: Int = 0,

    @CsvBindByPosition(position = 2)
    val gender: String = ""
)
CSVヘッダーあり
data class User(
    @CsvBindByName(column = "名前", required = true)
    val name: String = "",

    @CsvBindByName(column = "年齢", required = true)
    val age: Int = 0,

    @CsvBindByName(column = "性別", required = true)
    val gender: String = ""
)

書き込み

実装
        val strWriter = StringWriter()
        val beanWriter = StatefulBeanToCsvBuilder<User>(strWriter).build()
        beanWriter.write(User("三幸太郎", 45, "男"))
        beanWriter.write(User("三幸次郎", 44, "男"))
        beanWriter.write(User("三幸花子", 35, "女"))
        println(strWriter.toString())
出力(ヘッダー無し)
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"
出力(ヘッダーあり)
"名前","年齢","性別"
"三幸太郎","45","男"
"三幸次郎","44","男"
"三幸花子","35","女"

読み込み

読み込み元は、先ほど作成したCSVテキスト(strWriter)

実装
        val strReader = StringReader(strWriter.toString())
        val beanList = CsvToBeanBuilder<User>(strReader)
            .withType(User::class.java)
            .withIgnoreLeadingWhiteSpace(true)
            .build()
            .parse()
        for (user in beanList) {
            println(String.format("%s, %d, %s", user.name, user.age, user.gender))
        }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android/Kotlin】複数のWidgetが同期して同じ動作をしてしまう場合の解決方法

はじめに

この記事は【Android/Kotlin】Widgetを作ってクリック時の動作を設定する
の続きとなっていますすが,ウィジェットの基本が分かっている方はこの記事だけ見れば大丈夫です。

また,執筆者は趣味レベルのAndroidアプリ開発者である為,説明間違いが含まれている可能性があります。お気づきの点がありましたら,是非コメントお願いいたします。また,アップデートにより,機能やコードが記事執筆時点と異なる場合があります

本記事の内容

本記事では,【Android/Kotlin】Widgetを作ってクリック時の動作を設定する
で作成した,ボタンを押すとカウンターが1ずつ増えていくウィジェットについて,複数が同じものとして動作してしまう問題を解消します。
ウィジェットボタン問題.gif

修正毎のソースコード

特に複雑なことはしていませんが,ソースに関する詳しい説明は【Android/Kotlin】Widgetを作ってクリック時の動作を設定するを御覧ください。

ウィジェットのレイアウトファイル

sample_app_widget.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="@dimen/widget_margin">

    <TextView
        android:id="@+id/counter"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        android:textAppearance="@style/TextAppearance.AppCompat.Display1" />

    <Button
        android:id="@+id/add_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="add" />

</LinearLayout>

ウィジェットの動作を実装したファイル

SampleAppWidget.kt
//com.example.android.stackwidgetの部分は自分のアプリのIDに修正する
private const val COUNT_UP = "com.example.android.buttonwidgettest.COUNT_UP"

class SampleAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)

        if (context == null || intent == null) return

        when (intent.action) {
            COUNT_UP -> {
                // カウント値を読み込み
                val dataStore = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
                var clickCount = dataStore.getInt("clickCount", -1)

                clickCount++

                // カウントアップ後のカウント値を書き込み
                dataStore.edit().putInt("clickCount", clickCount).commit()

                //TextViewにカウント値を適用
                val views = RemoteViews(context.packageName, R.layout.sample_app_widget)
                views.setTextViewText(R.id.counter, clickCount.toString())

                // ウィジェットを更新
                val myWidget = ComponentName(context, SampleAppWidget::class.java)
                val manager = AppWidgetManager.getInstance(context)
                manager.updateAppWidget(myWidget, views)
            }
        }
    }
}

//onUpdateのappWidgetId毎の処理はこちらで実装する
internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int
) {

    // SharedPreferencesからデータを読み出し(登録されていない場合は0)
    val dataStore = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
    var clickCount = dataStore.getInt("clickCount", -1)
    if (clickCount == -1) {
        dataStore.edit().putInt("clickCount", 0).apply()
        clickCount = 0
    }

    // RemoteViews オブジェクトを作成
    val views = RemoteViews(context.packageName, R.layout.sample_app_widget)

    //TextViewにカウント値を適用
    views.setTextViewText(R.id.counter, clickCount.toString())

    //Button押下通知用のPendingIntentを作成しに登録
    val countIntent = Intent(context, SampleAppWidget::class.java).apply { action = COUNT_UP }
    val countPendingIntent =
        PendingIntent.getBroadcast(context, 0, countIntent, PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.add_button, countPendingIntent)

    // ウィジェットを更新
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

修正毎のソースコード

ウィジェットのレイアウトファイルの変更はありません。

AppWidgetProviderを継承したクラスのファイルを以下のように修正しています。

SampleAppWidget.kt
//com.example.android.stackwidgetの部分は自分のアプリのIDに修正する
private const val COUNT_UP = "com.example.android.buttonwidgettest.COUNT_UP"
private const val ARG_APP_WIDGET_ID = "appWidgetId"

class SampleAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)

        if (context == null || intent == null) return

        when (intent.action) {
            COUNT_UP -> {

                // 修正:IntentからappWidgetIdを取得し,appWidgetIdを用いてにカウント値を読み書き
                val appWidgetId = intent.getIntExtra(ARG_APP_WIDGET_ID, 0)

                // カウント値を読み込み
                val dataStore =
                    context.getSharedPreferences("widgetClickCount", Context.MODE_PRIVATE)
                var clickCount = dataStore.getInt(appWidgetId.toString(), -1)

                clickCount++

                // カウントアップ後のカウント値を書き込み
                dataStore.edit().putInt(appWidgetId.toString(), clickCount).commit()

                //TextViewにカウント値を適用
                val views = RemoteViews(context.packageName, R.layout.sample_app_widget)
                views.setTextViewText(R.id.counter, clickCount.toString())

                // 修正:ウィジェット更新を通知する際に,appWidgetIdを指定
                // ウィジェットを更新
                val manager = AppWidgetManager.getInstance(context)
                manager.updateAppWidget(appWidgetId, views)
            }
        }
    }
}

//appWidgetId毎のonUpdateの処理はこちらで実装する
internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int
) {

    // 修正:SharedPreferencesにカウント値を保存する際,appWidgetId毎に保存するように修正G

    // SharedPreferencesからデータを読み出し(登録されていない場合は0)
    val dataStore = context.getSharedPreferences("widgetClickCount", Context.MODE_PRIVATE)
    var clickCount = dataStore.getInt(appWidgetId.toString(), -1)
    if (clickCount == -1) {
        dataStore.edit().putInt(appWidgetId.toString(), 0).apply()
        clickCount = 0
    }

    // RemoteViews オブジェクトを作成
    val views = RemoteViews(context.packageName, R.layout.sample_app_widget)

    //TextViewにカウント値を適用
    views.setTextViewText(R.id.counter, clickCount.toString())

    //Button押下通知用のPendingIntentを作成しに登録
    val countIntent = Intent(context, SampleAppWidget::class.java).apply {
        action = COUNT_UP
        // 修正:IntentにappWidgetIdを付加
        putExtra(ARG_APP_WIDGET_ID, appWidgetId)
    }

    // 修正:PendingIntentのrequestCodeにappWidgetIdを指定
    val countPendingIntent =
        PendingIntent.getBroadcast(
            context,
            appWidgetId,
            countIntent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )

    views.setOnClickPendingIntent(R.id.add_button, countPendingIntent)

    // ウィジェットを更新
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

修正点は以下のとおりです。

  • 修正1
    • SharedPreferencesにカウント値を保存する際,keyをappWidgetId,valueをカウント値として,appWidgetId毎にカウント値を保存するように修正しました。
  • 修正2
    • Intentに,putExtraを用いてappWidgetIdを付加しました。 これにより,Intentを受信した際,onReceiveでappWidgetIdを参照することが可能になります。
  • 修正3
    • PendingIntentのrequestCodeにappWidgetIdを指定しました。
  • 修正4
    • onReceive内で,受け取ったIntentからappWidgetIdを取得し,appWidgetIdを用いてSharedPreferencesにカウント値を読み書きするようにしました
  • 修正5
    • onReceive内で,ウィジェット更新を通知する際に,appWidgetIdを指定するようにしました

修正1・3は,このアプリの特有の仕様によるものですのであまり重要ではありませんが,
修正2・4・5は,ウィジェット毎に各自の動作を行いたい場合には留意するべき点であると思います。

おわりに

もし説明や実装におかしな点がありましたら是非コメントください。
修正させて頂きます。

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

【Android/Kotlin】同じWidgetを複数作成した際に,別々のものとして動作させる方法

はじめに

この記事は【Android/Kotlin】Widgetを作ってクリック時の動作を設定する
の続きとなっていますすが,ウィジェットの基本が分かっている方はこの記事だけ見れば大丈夫です。

また,執筆者は趣味レベルのAndroidアプリ開発者である為,説明間違いが含まれている可能性があります。お気づきの点がありましたら,是非コメントお願いいたします。また,アップデートにより,機能やコードが記事執筆時点と異なる場合があります

本記事の内容

本記事では,【Android/Kotlin】Widgetを作ってクリック時の動作を設定する
で作成した,ボタンを押すとカウンターが1ずつ増えていくウィジェットについて,複数が同じものとして動作してしまう問題を解消します。
ウィジェットボタン問題.gif

修正毎のソースコード

特に複雑なことはしていませんが,ソースに関する詳しい説明は【Android/Kotlin】Widgetを作ってクリック時の動作を設定するを御覧ください。

ウィジェットのレイアウトファイル

sample_app_widget.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="@dimen/widget_margin">

    <TextView
        android:id="@+id/counter"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        android:textAppearance="@style/TextAppearance.AppCompat.Display1" />

    <Button
        android:id="@+id/add_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="add" />

</LinearLayout>

ウィジェットの動作を実装したファイル

SampleAppWidget.kt
//com.example.android.stackwidgetの部分は自分のアプリのIDに修正する
private const val COUNT_UP = "com.example.android.buttonwidgettest.COUNT_UP"

class SampleAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)

        if (context == null || intent == null) return

        when (intent.action) {
            COUNT_UP -> {
                // カウント値を読み込み
                val dataStore = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
                var clickCount = dataStore.getInt("clickCount", -1)

                clickCount++

                // カウントアップ後のカウント値を書き込み
                dataStore.edit().putInt("clickCount", clickCount).commit()

                //TextViewにカウント値を適用
                val views = RemoteViews(context.packageName, R.layout.sample_app_widget)
                views.setTextViewText(R.id.counter, clickCount.toString())

                // ウィジェットを更新
                val myWidget = ComponentName(context, SampleAppWidget::class.java)
                val manager = AppWidgetManager.getInstance(context)
                manager.updateAppWidget(myWidget, views)
            }
        }
    }
}

//onUpdateのappWidgetId毎の処理はこちらで実装する
internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int
) {

    // SharedPreferencesからデータを読み出し(登録されていない場合は0)
    val dataStore = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
    var clickCount = dataStore.getInt("clickCount", -1)
    if (clickCount == -1) {
        dataStore.edit().putInt("clickCount", 0).apply()
        clickCount = 0
    }

    // RemoteViews オブジェクトを作成
    val views = RemoteViews(context.packageName, R.layout.sample_app_widget)

    //TextViewにカウント値を適用
    views.setTextViewText(R.id.counter, clickCount.toString())

    //Button押下通知用のPendingIntentを作成しに登録
    val countIntent = Intent(context, SampleAppWidget::class.java).apply { action = COUNT_UP }
    val countPendingIntent =
        PendingIntent.getBroadcast(context, 0, countIntent, PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.add_button, countPendingIntent)

    // ウィジェットを更新
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

修正毎のソースコード

ウィジェットのレイアウトファイルの変更はありません。

AppWidgetProviderを継承したクラスのファイルを以下のように修正しています。

SampleAppWidget.kt
//com.example.android.stackwidgetの部分は自分のアプリのIDに修正する
private const val COUNT_UP = "com.example.android.buttonwidgettest.COUNT_UP"

class SampleAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)

        if (context == null || intent == null) return

        when (intent.action) {
            COUNT_UP -> {

                // 修正:IntentからappWidgetIdを取得し,appWidgetIdを用いてにカウント値を読み書き
                val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0)

                // カウント値を読み込み
                val dataStore =
                    context.getSharedPreferences("widgetClickCount", Context.MODE_PRIVATE)
                var clickCount = dataStore.getInt(appWidgetId.toString(), -1)

                clickCount++

                // カウントアップ後のカウント値を書き込み
                dataStore.edit().putInt(appWidgetId.toString(), clickCount).commit()

                //TextViewにカウント値を適用
                val views = RemoteViews(context.packageName, R.layout.sample_app_widget)
                views.setTextViewText(R.id.counter, clickCount.toString())

                // 修正:ウィジェット更新を通知する際に,appWidgetIdを指定
                // ウィジェットを更新
                val manager = AppWidgetManager.getInstance(context)
                manager.updateAppWidget(appWidgetId, views)
            }
        }
    }
}

//appWidgetId毎のonUpdateの処理はこちらで実装する
internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int
) {

    // 修正:SharedPreferencesにカウント値を保存する際,appWidgetId毎に保存するように修正G

    // SharedPreferencesからデータを読み出し(登録されていない場合は0)
    val dataStore = context.getSharedPreferences("widgetClickCount", Context.MODE_PRIVATE)
    var clickCount = dataStore.getInt(appWidgetId.toString(), -1)
    if (clickCount == -1) {
        dataStore.edit().putInt(appWidgetId.toString(), 0).apply()
        clickCount = 0
    }

    // RemoteViews オブジェクトを作成
    val views = RemoteViews(context.packageName, R.layout.sample_app_widget)

    //TextViewにカウント値を適用
    views.setTextViewText(R.id.counter, clickCount.toString())

    //Button押下通知用のPendingIntentを作成しに登録
    val countIntent = Intent(context, SampleAppWidget::class.java).apply {
        action = COUNT_UP
        // 修正:IntentにappWidgetIdを付加
        putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
    }

    // 修正:PendingIntentのrequestCodeにappWidgetIdを指定
    val countPendingIntent =
        PendingIntent.getBroadcast(
            context,
            appWidgetId,
            countIntent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )

    views.setOnClickPendingIntent(R.id.add_button, countPendingIntent)

    // ウィジェットを更新
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

修正点は以下のとおりです。
修正1・4は,このアプリの特有の仕様によるものですのであまり重要ではありませんが,
修正2・3・5は,ウィジェット毎に各自の動作を行いたい場合には必須の実装かと思います。

修正1

SharedPreferencesにカウント値を保存する際,keyをappWidgetId,valueをカウント値として,appWidgetId毎にカウント値を保存するように修正しました。

修正2

Intentに,putExtraを用いてappWidgetIdを付加しました。
これにより,Intentを受信した際,onReceiveでappWidgetIdを参照することが可能になります。

修正3

PendingIntentのrequestCodeにappWidgetIdを指定しました。

※因みに余談ですが,他の修正点を実装したうえでappWidgetIdを設定しない(=0を設定)場合はどうなるかというと,どのボタンを押しても最新のウィジェットの値だけが更新されるという現象が起こります。これは,FLAG_UPDATE_CURRENTフラグの挙動に関係あります。このフラグを設定した際,同じrequestCodeで新規にPendingIntentを作成する度,いままで作成したPendingIntentに設定したIntentが全て,新しく設定したIntentに置き換わってしまいます。(つまりCURRENTのものにUPDATEされている)
他のFLAGに関してはこちらの記事で分かりやすく解説されています。(他のFLAGを設定した場合にもそれぞれでおかしな動作が発生しますので興味ある方は試してみてください。)とりあえずどのFLAGを設定する場合でも,別々のPendingIntentを複数作成したい場合には,requestCodeにはユニークな値を設定したほうが良いです。

修正4

onReceive内で,受け取ったIntentからappWidgetIdを取得し,appWidgetIdを用いてSharedPreferencesにカウント値を読み書きするようにしました

修正5

onReceive内で,ウィジェット更新を通知する際に,appWidgetIdを指定するようにしました

おわりに

もし説明や実装におかしな点がありましたら是非コメントください。
修正させて頂きます。

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

【Kotlin】AndroidでiOSライクな画面遷移アニメーションを実装する

簡単に言えばiOSでUINavigationControllershowするような画面遷移を、
Androidで簡単に実装出来ます。

手順1:res/animを作成する

スクリーンショット 2020-09-10 11.39.28.png

手順2:画像のようにxmlファイルを4件追加する

close_enter.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="-100%"
        android:toXDelta="0%"
        android:duration="300"
        android:fillAfter="true"
        android:fillEnabled="true"/>
</set>

close_exit.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="0%"
        android:toXDelta="100%"
        android:duration="300"
        android:fillAfter="true"
        android:fillEnabled="true"/>
</set>

open_enter.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="100%"
        android:toXDelta="0%"
        android:duration="300"
        android:fillAfter="true"
        android:fillEnabled="true"/>
</set>

open_exit.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="0%"
        android:toXDelta="-100%"
        android:duration="300"
        android:fillAfter="true"
        android:fillEnabled="true"/>
</set>

手順3:アニメーションを行いたいA画面の画面遷移実行時に下記の記述を入れる。

val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
overridePendingTransition(R.anim.open_enter, R.anim.open_exit);

手順3:閉じるアニメーションを行いたいB画面の画面遷移実行時に下記の記述を入れる。

    override fun finish() {
        super.finish()
        overridePendingTransition(R.anim.close_enter, R.anim.close_exit);
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[備忘録] MacBook Pro を使って Android 10 を Raspberry Pi 4 に焼く

Linux を使える環境だったら全く問題ないんですが、 macOS を搭載した MacBook Pro で Android 10 を Raspberry Pi に焼く時にいくつかハマるポイントがあったので備忘録がてら残しておく。

macOS で、とありますが Docker や VirtualBox で Linux は使いますので Linux を使わずに Android を焼く。という縛りプレイとかではないです。 Linux系OS の起動ディスク作ったりが面倒だったのでそれをやらずに焼く方法。という感じです。

主に以下で公開されている内容に沿って進めていきます。
https://github.com/android-rpi/device_arpi_rpi4

この記事もわかりやすくまとめられています。 Linux が使える環境であればこちらを読んでその通りに進めていければいいと思います。
https://medium.com/@jpnurmi/android-10-on-raspberry-pi-4-5078901698dd

Android のソースは以下のサイトからダウンロードしてきます。
https://source.android.google.cn/?hl=ja

ここに書いてあることが非常に重要で、macOS は多くの場合でファイルの大文字、小文字を区別しないファイルシステム上で動いているんですが、そのせいで macOS の環境で Android のビルドを始めると途中でコケます。以下のコマンドでどこかしらに android.dmg.sparseimage を作成してマウントし、そこで基本的に作業を進めるようにしてください。

$ hdiutil create -type SPARSE -fs 'Case-sensitive Journaled HFS+' -size 250g ~/android.dmg.sparseimage

https://source.android.google.cn/setup/build/initializing?hl=ja#setting-up-a-mac-os-x-build-environment

それでは、 Android のソースコードをダウンロードします。以下のコマンドを打ち込んでいってください。最後の repo sync コマンドは -j{任意の数字} を入れると数字分並列して実行してくれるため高速化できることがあります。

$ repo init -u https://android.googlesource.com/platform/manifest -b android-10.0.0_r41
$ git clone https://github.com/android-rpi/local_manifests .repo/local_manifests -b arpi-10
$ repo sync

https://github.com/android-rpi/local_manifests

repo コマンドは Google 製のリポジトリ管理ツールとのことです。入れていなかったら以下を参考に入れてください。

https://source.android.google.cn/setup/develop?hl=ja

次に、カーネルのビルドをします。上記でダウンロードしたソースに kernel ディレクトリが存在することを確認して進めてください。また、この作業は macOS だと上手くいかなかったので VirtualBox を使い Ubuntu 20.04 でやりました。

$ sudo apt install gcc-arm-linux-gnueabihf libssl-dev build-essential bs python-mako
$ cd kernel/arpi
$ ARCH=arm scripts/kconfig/merge_config.sh arch/arm/configs/bcm2711_defconfig kernel/configs/android-base.config kernel/configs/android-recommended.config
$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make zImage
$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make dtbs

結構時間かかります。また環境によってはライブラリが足りずコケるかもしれませんので適宜追加して進めてください。

次に、以下にあるように一部ソースコードを書き換えます。

https://github.com/android-rpi/device_arpi_rpi4/wiki/Android-10-:-patch-framework-source

それが済んだらいよいよ Android のビルドです。 macOS 上で Android のソースコードのディレクトリで以下の通り作業をします。
macOS のバージョンによっては SDK のバージョンが新しすぎて途中でビルドがコケる。ということもあったのでその場合は古い SDK を入れて進めました。

$ source build/envsetup.sh
$ lunch rpi4-eng
$ make ramdisk systemimage vendorimage

次に、 Android を焼く SD カードのフォーマットをします。 macOS 10.15 でこの通りパーティションを以下の通りに分けることはできなかったので再び VirtualBox の Ubuntu 20.04 上でやりました。

 Partitions of the card should be set-up like followings.
  p1 128MB for boot : Do fdisk, set W95 FAT32(LBA) & Bootable type, mkfs.vfat
  p2 768MB for /system : Do fdisk, new primary partition
  p3 128MB for /vendor : Do fdisk, new primary partition
  p4 remainings for /data : Do fdisk, mkfs.ext4
 Set volume label of /data partition as userdata

ミソは p4 を userdata という label にすることと、 p1 にきちんと boot と lba のフラグをつけることです。
VirtualBox を使っているなら以下のページにある画像の通りに Gparted を使えばいけると思います。
https://medium.com/@jpnurmi/android-10-on-raspberry-pi-4-5078901698dd

最後に以下の通り SD カードにビルドした Android をコピーしていけば完成です。

# Write system & vendor partition
  $ cd out/target/product/rpi4
  $ sudo dd if=system.img of=/dev/<p2> bs=1M
  $ sudo dd if=vendor.img of=/dev/<p3> bs=1M

# Copy kernel & ramdisk to BOOT partition
  device/arpi/rpi4/boot/* to p1:/
  kernel/arpi/arch/arm/boot/zImage to p1:/
  kernel/arpi/arch/arm/boot/dts/bcm2711-rpi-4-b.dtb to p1:/
  kernel/arpi/arch/arm/boot/dts/overlays/vc4-kms-v3d-pi4.dtbo to p1:/overlays/
  out/target/product/rpi4/ramdisk.img to p1:/

だいぶ雑な感じですが自分用の備忘録ということで。

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

【Android開発】Notification Channelごとに通知をグループ化する方法

概要

下記画像のように、プッシュ通知をチャンネルごとにグループ化する方法を紹介します。
チャンネルごとに、プッシュ通知をグループ化して受信トレイに表示させたいという方には参考になると思います。

スクリーンショット 2020-09-09 23.55.59.png

また、Githubにソースコードをあげているので、参考にしてみてください。
https://github.com/YusukeSuzuki1213/notification-channel-group

注意点としては、OS8以上でなければ通知チャンネルを使用することができないことと、OS7以上でなければ通知のグループ化を実装することができないことです。

実装方法

1. チャンネルの作成

まず下記のようにチャンネルを作成するメソッドを作成します。

fun createChannel(context: Context) {
    NotificationManagerCompat.from(context).createNotificationChannels(
        ChannelItem.values().map {
            NotificationChannel(
                it.channelId,
                context.getString(it.getTextStringRes()),
                NotificationManager.IMPORTANCE_HIGH
            )
        }
    )
}

NotificationManagerCompat.from(context).createNotificationChannelsにおいて、アプリの通知チャンネルを作成しています。引数にはList<NotificationChannel>型のインスタンスを指定しています。

NotificationChannelクラスはチャンネルの設定についてのクラスで、インスタンス化には、チャンネルのId、チャンネルの名前、Importanceを指定しています。Importanceとは、通知受信時に音を出すか、ヘッドアップ通知とするかなどの通知の重要度を指定することができます。詳しくはNotification importanceを参考にしてください。

チャンネル情報をまとめたクラスの作成

ChannelItem.values().mapとしているように、今回は複数チャンネルを作成するため、下記のようにチャンネルの情報をまとめたChannelItemというEnumクラスを作成します。

enum class ChannelItem(val channelId: String) {
    CHANNEL_1("notification_channel_1"),
    CHANNEL_2("notification_channel_2"),
    CHANNEL_3("notification_channel_3");

    companion object {
        @JvmStatic
        fun value(channelId: String) : ChannelItem? {
            return try {
                values().first {it.channelId == channelId}
            } catch (_: NoSuchElementException) {
                null
            }
        }
    }

    fun getTextStringRes(): Int {
        return when(this) {
            CHANNEL_1 -> R.string.channel_name_notification_1
            CHANNEL_2 -> R.string.channel_name_notification_2
            CHANNEL_3 -> R.string.channel_name_notification_3
        }
    }
}

EnumクラスのプロパティにチャンネルのIdを持たせています。さらに、getTextStringRes()というメソッドでEnumクラスのインスタンスから、チャンネルの名前を取得できるようにしています。

上記の実装でチャンネルの作成ができます。

2. プッシュ通知の作成

次に下記のように、実際にプッシュ通知を作成するメソッドを作成します。

fun localPush(context: Context, item: PushMessageItem) {

    val channelItem = ChannelItem.value(item.channelId) ?: return

    val builderParent = NotificationCompat.Builder(context, item.channelId)
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setGroup(item.channelId)
        .setSmallIcon(R.drawable.ic_launcher_foreground)
        .setStyle(
            NotificationCompat.InboxStyle()
                .setSummaryText(
                    context.getString(channelItem.getTextStringRes())
                ))
        .setGroupSummary(true)
        .setAutoCancel(true)

    val builderChild = NotificationCompat.Builder(context, item.channelId)
        .setContentTitle(item.title)
        .setContentText(item.body)
        .setAutoCancel(true)
        .setDefaults(NotificationCompat.DEFAULT_SOUND)
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setGroup(item.channelId)
        .setSmallIcon(R.drawable.ic_launcher_foreground)        
}

実装のイメージは、builderParentと呼ばれる親通知の中に、builderChildと呼ばれる複数の子通知が追加されていく感じです。

builderParent について

画像のように、通知グループの概要を表示する通知になっています。

スクリーンショット 2020-09-09 23.48.20.png

  • setGroup()について
    グループを識別する文字列を渡し、親通知をグループに追加しています。

  • setGruoupSummary()について
    引数にtrueを渡すことで、builderParentがグループ概要を表示する親通知であるということ示しています。

  • setStyle()について
    通知のレイアウトを指定しています。今回はNotificationCompat.InboxStyleを指定することで、通知の概要を追加しています。
    詳しくは展開可能な通知を作成するを参考にしてください。

builderChild について

画像のように、実際のプッシュ通知の情報を持ったインスタンスになります。通知音の設定や、タイトルの指定などを行なっています。

スクリーンショット 2020-09-09 23.49.23.png

またsetGroup()で親通知と同じグループを識別文字列を指定することで、グループに属する子通知であることを示しています。

プッシュ通知の表示

下記コードにおいて、実際にプッシュ通知を表示しています。

 NotificationManagerCompat.from(context).run {
        notify(channelItem.ordinal, builderParent.build())
        notify(System.currentTimeMillis().toInt(), builderChild.build())
 }

notifyの第一引数には、通知のIDを指定しています。

このIDは通知を一意に識別するIDとなっています。このIDが重複してしまうと、通知が上書きされてしまいます。逆にIDが一意だと、異なる通知として表示されます。

親通知は通知が来るたびに、異なる通知として扱う必要がないため、channelItem.ordinalを指定して、通知グループごと同じIDをしてしています。

子通知は、通知が上書きされないように、タイムスタンプをIDとして指定しています。

まとめ

Notification Channelごとに通知をグループ化する方法を紹介しました。

参考になればうれしいです。

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