Laravel(PHP)からSlackにメッセージを送信する

Laravel(PHP)からSlackにメッセージを送信する方法を紹介します。

以前にGuzzleを使った方法を紹介しました。
今回はLaravelのHTTPクライアントを使用した方法を紹介します。

まず、Slackにメッセージを送信するためのサービスクラスを作成します。以下はそのコード例です。

<?php
declare(strict_types=1);

namespace App\Utils;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class SlackService
{
    public static function post(string $message): bool
    {
        $webhookUrl = config('slack.webhook_url');
        $username = config('slack.username');
        $iconEmoji = config('slack.icon_emoji');

        try {
            Http::acceptJson()
                ->post($webhookUrl, [
                        'username' => $username,
                        'text' => $message,
                        'icon_emoji' => $iconEmoji,
                    ]
                )->throw();
            return true;
        } catch (RequestException $e) {
            Log::error('Slack Post Failed: '.$e->getMessage());
            Log::error('Failed message: '.$message);
            Log::error($e->getTraceAsString());
            return false;
        }
    }
}

このコードは、LaravelのHTTPクライアントを使用してSlackにメッセージを送信します。
設定ファイルからWebhook URL、ユーザー名、およびアイコンの絵文字を取得し、メッセージを送信します。

Laravelの設定ファイル

SlackのWebhook URLやその他の設定は、Laravelの設定ファイルに記載します。

例えば、config/slack.phpというファイルを作成し、以下のように設定します。

<?php

return [
    'webhook_url' => env('SLACK_WEBHOOK_URL'),
    'username' => env('SLACK_USERNAME', 'LaravelBot'),
    'icon_emoji' => env('SLACK_ICON_EMOJI', ':robot_face:'),
];

また、.envファイルに以下のように追加します。

SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url
SLACK_USERNAME=LaravelBot
SLACK_ICON_EMOJI=:robot_face:

以上で、LaravelからSlackにメッセージを送信する準備が整います。

必要に応じて、設定ファイルやコードを調整してください。

Compose for Desktopアプリでドラッグしてコントロールのサイズを変更する

Compose for Desktopでドラッグしてコントロールのサイズを変更するサンプルプログラムです。

2つのテキストエリアを分割線で分け、その分割線をドラッグしてテキストエリアの幅を調整します。

画面イメージ

サンプルプログラムの説明

App関数

アプリケーションのメインビューを定義します。

このビューには、Cyan色のテキスト、ドラッグ可能なDivider、そしてYellow色のテキストが含まれています。

テキストの幅は、ドラッグによって変更可能です。

@Composable
@Preview
fun App() {
    // 幅を保持するState
    val widthText = remember { mutableStateOf(200.dp) }
    MaterialTheme {
        Row(modifier = Modifier.fillMaxSize()) {
            DisplayText(widthText.value, Color.Cyan)
            DisplayDivider(widthText)
            DisplayText(200.dp, Color.Yellow)
        }
    }
}

DisplayText

指定された幅と色でテキストを表示します。

テキストは幅の値で、背景色は指定された色です。

@Composable
fun DisplayText(width: Dp, color: Color) {
    Text(
        "${width.value.toInt()}dp",
        modifier = Modifier.width(width).fillMaxHeight().background(color = color)
    )
}

DisplayDivider関数

ドラッグで幅を変更できるDivider(区切り線)を表示します。

このDividerは、ドラッグジェスチャーを検出し、ドラッグの量に基づいて幅を変更します。

また、マウスが上にホバーすると、カーソルがリサイズカーソルに変わります。

@Composable
fun DisplayDivider(widthText: MutableState<Dp>) {
    // 画面密度
    val density = LocalDensity.current

    Divider(
        color = Color.Red,
        modifier = Modifier
            .fillMaxHeight()
            // 幅を指定
            .width(8.dp)
            // ドラッグジェスチャーを検出するためのModifier
            .pointerInput(Unit) {
                // ドラッグジェスチャーを検出
                detectDragGestures { change, dragAmount ->
                    // ドラッグ量を取得し、幅を変更
                    with(density) {
                        val dragAmountDp = dragAmount.x.toDp()
                        val newWidth = (widthText.value + dragAmountDp).coerceIn(50.dp, 400.dp)
                        if (change.pressed) {
                            widthText.value = newWidth
                        }
                    }
                }
            }
            // マウスホバー時のカーソルを変更
            .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
    )
}

サンプルプログラム全文

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import java.awt.Cursor

/**
 * 指定された幅と色でテキストを表示します。
 * テキストは幅の値で、背景色は指定された色です。
 *
 * @param width 幅
 * @param color 背景色
 */
@Composable
fun DisplayText(width: Dp, color: Color) {
    Text(
        "${width.value.toInt()}dp",
        modifier = Modifier.width(width).fillMaxHeight().background(color = color)
    )
}

/**
 * ドラッグで幅を変更できるDividerを表示します。
 *
 * @param widthText 幅を保持するState
 */
@Composable
fun DisplayDivider(widthText: MutableState<Dp>) {
    // 画面密度
    val density = LocalDensity.current

    Divider(
        color = Color.Red,
        modifier = Modifier
            .fillMaxHeight()
            // 幅を指定
            .width(8.dp)
            // ドラッグジェスチャーを検出するためのModifier
            .pointerInput(Unit) {
                // ドラッグジェスチャーを検出
                detectDragGestures { change, dragAmount ->
                    // ドラッグ量を取得し、幅を変更
                    with(density) {
                        val dragAmountDp = dragAmount.x.toDp()
                        val newWidth = (widthText.value + dragAmountDp).coerceIn(50.dp, 400.dp)
                        if (change.pressed) {
                            widthText.value = newWidth
                        }
                    }
                }
            }
            // マウスホバー時のカーソルを変更
            .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
    )
}

@Composable
@Preview
fun App() {
    // 幅を保持するState
    val widthText = remember { mutableStateOf(200.dp) }
    MaterialTheme {
        Row(modifier = Modifier.fillMaxSize()) {
            DisplayText(widthText.value, Color.Cyan)
            DisplayDivider(widthText)
            DisplayText(200.dp, Color.Yellow)
        }
    }
}

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        state = WindowState(size = DpSize(500.dp, 200.dp))
    ) {
        App()
    }
}

既知の問題

マルチディスプレイ環境において、異なるDPIのディスプレイにウィンドウを移動すると、ドラッグした距離を正しく計算できません。

Compose for Desktopでタブコントロールを使う

Compose for Desktopでタブコントロールを使ったサンプルプログラムです。

タブを使用して異なるページを切り替えて表示します。

画面イメージ

サンプルプログラム

App関数は、アプリケーションの UI を定義するコンポーズ可能な関数です。
タブタイトルのリストと、対応するページのリストを作成します。
各ページは、特定の色でページを表示する構成可能な関数です。
次に、App 関数はPageControl関数を使用して、タブと現在選択されているページを表示します。

@Composable
@Preview
fun App() {
    val tabTitles = listOf("Page1", "Page2", "Page3")
    val pages: List<@Composable () -> Unit> = listOf(
        { Page("Page1", Color.White) },
        { Page("Page2", Color.Yellow) },
        { Page("Page3", Color.Cyan) }
    )
    MaterialTheme {
        PageControl(tabTitles, pages)
    }
}

PageControl関数は、tabTitlesリスト内の各タイトルのタブを含むTabRowを表示するコンポーズ可能な関数です。
ページリストから現在選択されているページも表示されます。
選択したタブは変更可能な状態で保存され、タブがクリックされると更新されます。

@Composable
fun PageControl(tabTitles: List<String>, pages: List<@Composable () -> Unit>) {
    var selectedTabIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabTitles.forEachIndexed { index, title ->
                Tab(
                    text = { Text(title) },
                    selected = index == selectedTabIndex,
                    onClick = { selectedTabIndex = index }
                )
            }
        }
        pages.getOrNull(selectedTabIndex)?.invoke()
    }
}

Page関数は、特定の背景色とテキストを使用してページを表示するコンポーザブル関数です。

@Composable
fun Page(text: String, color: Color) {
    Text(
        modifier = Modifier.fillMaxSize().background(color),
        text = text
    )
}

サンプルプログラム全文

@Composable
@Preview
fun App() {
    val tabTitles = listOf("Page1", "Page2", "Page3")
    val pages: List<@Composable () -> Unit> = listOf(
        { Page("Page1", Color.White) },
        { Page("Page2", Color.Yellow) },
        { Page("Page3", Color.Cyan) }
    )
    MaterialTheme {
        PageControl(tabTitles, pages)
    }
}

/**
 * PageControl は、タブ付きページ コントロールの簡単な例です。
 * これは、TabRow とページのリストで構成されます。
 * TabRow は、タブのリストを表示し、選択されたタブのインデックスを保持します。
 * ページのリストは、選択されたタブのインデックスに基づいて、表示されるページを切り替えます。
 *
 * @param tabTitles タブのタイトルのリスト
 * @param pages ページのリスト
 */
@Composable
fun PageControl(tabTitles: List<String>, pages: List<@Composable () -> Unit>) {
    var selectedTabIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabTitles.forEachIndexed { index, title ->
                Tab(
                    text = { Text(title) },
                    selected = index == selectedTabIndex,
                    onClick = { selectedTabIndex = index }
                )
            }
        }
        pages.getOrNull(selectedTabIndex)?.invoke()
    }
}

/**
 * Page は、指定されたテキストと色で背景を持つページを表示します。
 * これは、fillMaxSize で背景色を設定し、テキストを表示します。
 *
 * @param text ページに表示するテキスト
 * @param color ページの背景色
 */
@Composable
fun Page(text: String, color: Color) {
    Text(
        modifier = Modifier.fillMaxSize().background(color),
        text = text
    )
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

Android14のSelected Photos Accessについて

問題

AndroidManifest.xmlの下記の行で警告が表示されます。

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

警告メッセージは以下の通りです。

Your app is currently not handling Selected Photos Access introduced in Android 14+ More... (⌘F1) 
Inspection info:Selected Photo Access is a new ability for users to share partial access to their photo library when apps request access to their device storage on Android 14+.  Instead of letting the system manage the selection lifecycle, we recommend you adapt your app to handle partial access to the photo library.

このエラーの原因と対策を調べました。

原因

この警告メッセージは、Android 14(APIレベル 33)で導入された「Selected Photos Access(選択した写真へのアクセス)」に対応していないことを指摘しています。

Android 14以降、ユーザーはアプリがデバイスのストレージにアクセスを要求する際に、写真ライブラリ全体へのアクセスを許可する代わりに、選択した写真への部分的なアクセスを共有する新しい機能を利用できます。

android.permission.READ_MEDIA_IMAGESパーミッションを使用することで、アプリはユーザーのデバイス上の画像にアクセスできますが、Android 14+では、ユーザーが選択した写真へのアクセスのみをアプリに許可するオプションが追加されています。この変更により、ユーザーのプライバシーがさらに強化され、アプリは必要な写真にのみアクセスできるようになります。

この警告に対処するためには、アプリを「Selected Photos Access」に対応させる必要があります。具体的には、アプリが選択した写真への部分的なアクセスを管理し、この新しいアクセス権限に適応するようにする必要があります。

Selected Photos Accessの特徴と動作

  • 部分的なアクセス許可:ユーザーはアプリに対して、写真ライブラリ全体ではなく、選択した写真にのみアクセスを許可できます。これにより、ユーザーはプライバシーを保護しつつ、アプリの機能を利用できます。

  • 動的なアクセス管理: ユーザーはいつでも設定を変更し、アプリがアクセスできる写真を追加または削除できます。アプリは、これらの変更をリアルタイムで反映する必要があります。

  • ユーザーインターフェースの調整: アプリは、選択された写真へのアクセスが許可された場合、ユーザーが新たにアクセスを許可した写真に簡単にアクセスできるようにユーザーインターフェースを調整する必要があります。

対応

Selected Photos Access を利用する

アプリで Selected Photos Access を利用するには、以下の手順が必要です。

  1. アプリのターゲット API レベルを 33 に設定します。
  2. アプリの manifest.xml ファイルに以下の権限を追加します。
    <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
    
  3. アプリで PhotoPicker クラスを使用して、ユーザーが写真や動画を選択できるようにします。

PhotoPicker クラスの使用

PhotoPicker クラスは、ユーザーが写真や動画を選択するための UI を提供します。このクラスを使用するには、以下の手順が必要です。

  1. PhotoPicker.Builder クラスのインスタンスを作成します。
  2. Builder インスタンスに、ユーザーが選択できる写真や動画の種類を指定します。
  3. Builder インスタンスの build() メソッドを呼び出して、PhotoPicker インスタンスを作成します。
  4. PhotoPicker インスタンスの start() メソッドを呼び出して、写真選択画面を表示します。

ユーザーが選択した写真や動画へのアクセス

ユーザーが写真や動画を選択した後、アプリは PhotoPicker インスタンスの getSelectedPhotos() メソッドを使用して、選択された写真や動画への URI を取得できます。

Selected Photos Access の制限事項

Selected Photos Access には、以下の制限事項があります。

  • ユーザーが選択した写真や動画のみアクセスできる
  • ユーザーがアプリをアンインストールすると、アプリは選択された写真や動画へのアクセス権を失う