2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Xで見つけたURLをDiscordやSlackへ保存するAndroidアプリを自作した

2
Posted at

自己紹介と導入

joです。東北工業大学の情報通信工学科で学びながら、インターンや個人開発でWeb・AI周辺のものを作っています。普段から新しい技術を試し、実際に使ってみて面倒なところを小さなツールにするのが好きです。

プロフィール:

今回の開発のきっかけは、スマホで完結していた操作の中に、毎回同じ面倒な手順があったことです。Xで見つけたURLをDiscordへ保存するとき、共有メニューからDiscordを選び、Discordへ移動し、保存したいTimesのスレッドを探して、そこで送信する必要がありました。PCを開く必要はありませんでしたが、スマホだけで毎回このフローを踏むのが面倒でした。

既製の選択肢もありましたが、当時見ていたアプリは約800円?でした。自分の用途は「共有されたURLを決めた場所へ送る」だけだったので、それなら自作してみようと思いました。

作ったものは、Androidの共有シートからURLやテキストを受け取り、登録したDiscordまたはSlackのWebhookへ送信する小さなアプリです。

処理の流れは次のとおりです。

リポジトリはこちらです。

現在はGitHub ReleasesからDebug APKを取得できるようにしています。

作ろうと思った理由

自分のTimesに、あとで読みたい記事や開発中に見つけた情報を投げることがあります。操作自体はスマホの中で完結していましたが、共有先にDiscordを選んだあと、Discordへ移動して、目的のTimesスレッドを選ぶという手順が毎回必要でした。

最初はWebhook URLを1つ登録して、そのURLへ共有内容を送るだけのアプリでした。実際に使ってみると、DiscordだけでなくSlackにも送りたい場面が出てきました。また、Discordでも通常チャンネルではなく、既存スレッドへ保存したいことがあります。

そこで、送信先を次のセットとして複数登録できるようにしました。

  • 送信先の名前
  • Webhook URL
  • 既存スレッドID(任意)

共有時に送信先が複数あれば選択ダイアログを表示し、1つだけならそのまま送信します。

Androidの共有シートから受け取る

共有シートから起動される入口は、ACTION_SEND です。AndroidManifest.xml では、テキスト共有を受け取るActivityとして登録しています。

<intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
</intent-filter>

Activity側では、共有されたテキストを Intent.EXTRA_TEXT から取得します。

private fun handleShare(intent: Intent) {
    val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim().orEmpty()
    if (sharedText.isBlank()) {
        Toast.makeText(this, "共有テキストを取得できませんでした", Toast.LENGTH_SHORT).show()
        finish()
        return
    }

    val items = destinations()
    if (items.isEmpty()) {
        Toast.makeText(this, "先にアプリを開いて送信先を設定してください", Toast.LENGTH_LONG).show()
        showSettings()
        return
    }

    if (items.size == 1) send(items[0], sharedText)
    else AlertDialog.Builder(this)
        .setTitle("送信先を選択")
        .setItems(items.map { it.name }.toTypedArray()) { _, which ->
            send(items[which], sharedText)
        }
        .setOnCancelListener { finish() }
        .show()
}

ここで重要なのは、共有処理の入力をURLだけに限定していない点です。text/plain として渡された本文をそのまま受け取るため、URLを共有するアプリによって少し形式が違っても、まずは送信できるようにしています。

Webhookを複数保存する

送信先は、端末のSharedPreferencesへJSON配列として保存しています。Webhook URLはソースコードやGitHubリポジトリには含めません。

data class Destination(
    val name: String,
    val webhook: String,
    val threadId: String
)

private fun saveDestinations(items: List<Destination>) {
    val array = JSONArray()
    items.forEach { item ->
        array.put(
            JSONObject()
                .put("name", item.name)
                .put("webhook", item.webhook)
                .put("threadId", item.threadId)
        )
    }

    prefs.edit()
        .putString("destinations", array.toString())
        .remove("webhook_url")
        .remove("thread_id")
        .apply()
}

最初の単一Webhook版から移行できるよう、destinations がまだ存在しない場合は、以前の webhook_url と thread_id を1件の送信先として読み込むようにしました。小さいアプリでも、実際に使い始めた後の設定データを壊さないことは大事でした。

DiscordとSlackをURL形式で判定する

送信先の種類を別の設定項目として増やす代わりに、Webhook URLのホスト名で判定しています。

val isDiscord = url.startsWith("https://discord.com/api/webhooks/") ||
    url.startsWith("https://discordapp.com/api/webhooks/")
val isSlack = url.startsWith("https://hooks.slack.com/services/")

if (!isDiscord && !isSlack) {
    Toast.makeText(
        this,
        "DiscordまたはSlackのWebhook URLを入力してください",
        Toast.LENGTH_SHORT
    ).show()
    return@setPositiveButton
}

送信時のJSONはサービスごとに異なります。

  • Discord: content
  • Slack: text
val payloadObject = if (isSlack) {
    JSONObject().put("text", content.take(3000)).apply {
        if (threadId.isNotBlank()) put("thread_ts", threadId)
    }
} else {
    JSONObject().put("content", content.take(2000))
}

Discordの既存スレッドはWebhook URLへ thread_id をクエリとして付け、SlackのスレッドはJSONの thread_ts として渡します。Slack Incoming Webhooksは text を含むJSONをPOSTする仕様です。

SlackのIncoming Webhooksについては、公式ドキュメントの手順に従ってSlackアプリを作成し、Incoming Webhooksを有効化して投稿先チャンネルを許可します。

Webhook URLは投稿権限を持つ秘密情報なので、GitHubやチャットには貼らないようにします。漏れた場合はサービス側で再発行・削除します。

最初にハマったところ

AndroidXの設定不足

androidx.core と androidx.appcompat を依存関係に追加しただけでは、次のエラーになりました。

Configuration ':app:debugRuntimeClasspath' contains AndroidX dependencies,
but the `android.useAndroidX` property is not enabled

そこで gradle.properties に以下を追加しました。

android.useAndroidX=true
android.enableJetifier=true

JavaとKotlinのJVMターゲット不一致

次に、Java側が1.8、Kotlin側が22というエラーが出ました。

Inconsistent JVM-target compatibility detected for tasks
'compileDebugJavaWithJavac' (1.8) and 'compileDebugKotlin' (22)

JavaとKotlinを17へ揃えました。

compileOptions {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
    jvmTarget = "17"
}

Material UI導入後の起動問題

設定画面を少し見やすくするために、MaterialCardViewやMaterialButtonを追加しました。しかし、テーマがAppCompatのままだとMaterialコンポーネントと合わない可能性があります。

<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar" />

また、生成物が残った状態でビルドしたところ、同じクラスが複数のDEXに定義されているエラーも出ました。このときはAndroid Studioの Build > Clean Project で生成物を消してから再ビルドしました。

Android 15のステータスバーとの重なり

名称未設定のデザイン.png
※当初の画像がなかったので再現で許してください😭

Android Studioのエミュレータで確認すると、タイトルや説明が時刻・通知・充電表示の下へ潜り込んでいました。targetSdk = 35 のエッジツーエッジ表示が関係していたため、システムバーのInsetsを取得してコンテンツ上部へ余白を追加しました。

ViewCompat.setOnApplyWindowInsetsListener(content) { view, insets ->
    val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    view.setPadding(24, 32 + bars.top, 24, 32 + bars.bottom)
    insets
}

このあたりは、ビルドが通るだけでは見つけにくい問題でした。実機・エミュレータで画面を見ることの重要性を感じました。

Material UIで設定画面を整える

このアプリは設定画面を開く時間が短く、機能も「送信先を登録する」が中心です。そのため、大きな画面遷移を増やすより、1画面で役割が分かる構成にしました。

  • 上部: アプリ名と用途
  • 使い方カード: 初回利用時の3ステップ
  • 送信先カード: 登録済み送信先と削除ボタン
  • 画面下部: 送信先追加ボタン
  • 送信先が増えた場合: ScrollViewで縦に確認

名称未設定のデザイン.png

設定画面を「入力欄が並んだだけ」にせず、最初に何をする画面なのかを説明するカードを置きました。Android公式のUI設計でも、テーマ・コンポーネント・アダプティブレイアウトを使って、さまざまな画面に対応する考え方が示されています。

GitHub ActionsでAPKをReleaseへ添付する

毎回Android StudioでAPKをビルドして、手元からGitHub Releaseへアップロードするのは面倒です。そこで、タグをpushしたらGitHub ActionsでAPKを作り、Releaseへ添付するようにしました。

on:
  workflow_dispatch:
  push:
    tags:
      - "v*"

permissions:
  contents: write

ビルドは次のコマンドです。

- name: Build debug APK
  run: ./gradlew assembleDebug

最初のworkflowでは、Android SDKセットアップ時に古い tools パッケージの取得で失敗しました。現在のworkflowでは、platform-tools、platforms;android-35、build-tools;35.0.0 を明示しています。

- name: Set up Android SDK
  uses: android-actions/setup-android@v3
  with:
    packages: platform-tools platforms;android-35 build-tools;35.0.0

v0.2.1 でActionsのビルド成功を確認し、その後の機能追加もタグからReleaseを作る運用にしました。ただし、現在添付しているのはDebug APKです。一般公開用の署名鍵管理やPlay Store配布はまだ行っていません。

OGPカードについて分かったこと

Discordへ送ったGitHub URLはカード表示されました。一方で、XのURLは同じように送ってもカード表示されないケースがありました。

これは単純に「Webhookだからカードが出ない」という話ではなく、リンク先サービス側のクローラー・埋め込み可否やDiscord側の扱いにも左右されます。今回のアプリはURLをWebhookへ送るところまでを責務にしており、外部ページのOGPを取得して独自Embedを組み立てる実装はしていません。

この切り分けから、アプリの責務を増やしすぎないことにしました。まずは共有されたテキストを目的の場所へ届ける。そのうえで必要になれば、サービスごとのEmbed対応を別機能として検討します。

公開してからの反応

完成後、Xで紹介し、いくつか会話が生まれました。既製アプリが有料であることや、既存の自動化を組む手間に対して、「自分の保存先へ送るだけの小さなアプリ」に需要があるかもしれないと感じました。

その反応を受けて、単にソースコードを置くだけでなく、GitHub ActionsでAPKをReleaseへ添付するところまで進めました。利用者にとっては、Android Studioを開いてビルドしなくても試せる方が入口としては低くなります。

投稿はこちらです。

今後やりたいこと

現時点で次の課題があります。

  • APKを正式署名して配布する
  • 送信先の編集機能を追加する
  • SlackのスレッドIDをより分かりやすく取得できる導線を作る
  • 失敗時にHTTPステータスやサービスからのエラーを表示する
  • iOSのShare Extensionに対応する

iOSについては、Issueを作成して実装を相談しています。iOSではShare Extension、Keychain、App Groupの組み合わせが候補になります。

まとめ

今回作ったものは、数百行規模の大きなアプリではありません。それでも、実際の面倒を1つ減らすために作り始めると、共有Intent、Webhook形式、端末保存、UI、Android 15のInsets、署名とRelease運用まで考えることになりました。

特に良かったのは、最初から大きなサービスを作ろうとせず、「共有したURLを自分の場所へ保存する」という1つの流れに絞ったことです。

小さな自作アプリでも、使ってみて、公開して、反応を受けて、次の機能を決める。このサイクルを回せたのが今回の一番の収穫でした。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?