こんにちは!普段はPythonを触っているのですが、今回はAndroidアプリ開発(Kotlin)に初めて挑戦してみました。
「仕事や出先から帰るときに、毎回『LINEのメッセージを送るのが面倒だな…』」という日常のちょっとしたストレスを解消するため、アイコンをタップするだけで現在地から移動手段と到着予定時刻を自動計算してLINEに飛ばしてくれるアプリを作りました。
しかもこのアプリ、コードのほとんどをGeminiと対話しながらノーコード/ローコード感覚(AIペアプログラミング)で作成しています。Pythonしか知らなかった私が、どうやってAndroidアプリを作り上げたのか、その全貌をご紹介します!
🛠️ システム構成図
このアプリは、AndroidのGPS機能から取得した現在地をもとに、外部のクラウドAPI(Google Maps・LINE)を叩くシンプルな構成になっています。
- Android端末(Kotlin): アイコンタップで起動し、GPSで現在地(緯度・経度)を取得。
- Google Distance Matrix API: 現在地から自宅までの移動時間と距離を計算して返却。
- アプリ内ロジック: 500m以内なら誤送信防止でキャンセル。20分以内なら「自転車」、それ以上なら「電車」を自動判定し、到着時刻を算出。
- LINE Messaging API: 到着予定時刻と「了解」「お酒買ってきて」などのクイックリプライボタン付きメッセージをご家族のLINEに送信。
📌 APIの利用手順と無料で使える範囲について
このアプリを動かすためには、事前にLINE DevelopersとGoogle Cloud ConsoleでそれぞれのAPIキーやアクセストークンを取得する必要があります。どちらも個人開発やテスト利用であれば完全に無料枠の範囲内で運用可能です!
1. LINE Messaging API の準備と無料枠
💡 無料で使える範囲
- LINE Official Account Manager(フリープラン): 月間 1,000通 までのメッセージ送信が無料です。
- 今回のような「自分から家族への通知用(1日数回程度)」であれば、フリープランの枠内で余裕で運用できます。
🛠️ 利用手順(取得までの流れ)
- LINE Developers コンソールにアクセスし、LINEアカウントでログインします。
- 新しい「プロバイダー」を作成し、その中に「Messaging API」タイプのチャンネルを作成します。
- 作成したチャンネルの管理画面(Messaging API設定タブ)から、「チャネルアクセストークン(長期)」を発行してコピーします。
- 「LINE Official Account Manager」からボットのQRコードを読み込み、友だち追加した上で、ご自身のユーザーID(
Uから始まる33桁の文字列)をAPI等で取得して設定します。
2. Google Maps Distance Matrix API の準備と無料枠
💡 無料で使える範囲
- Google Maps Platform 固有の無料枠: 毎月 $200 分の無料クレジット(Free Credit) が自動で付与されます。
- Distance Matrix API は 1,000リクエストあたり数ドル程度であるため、個人の帰宅通知用途であれば実質無料(月数百回〜数千回利用しても枠内)で収まります。
- ※利用にはGoogle Cloudアカウントの作成とクレジットカードの登録(本人確認用)が必要ですが、無料枠を超えない限り課金されることはありません。
🛠️ 利用手順(取得までの流れ)
- Google Cloud Console にアクセスし、プロジェクトを新規作成します。
- 「API とサービス」>「ライブラリ」から 「Distance Matrix API」 を検索し、有効化(Enable)します。
- 「認証情報(Credentials)」ページから 「API キー」 を作成し、コピーします。
- (セキュリティのため、APIキーの設定画面で「API の制限」を行い、Distance Matrix API のみにアクセスを許可しておくことを強くおすすめします。)
- 参考: Google Maps Distance Matrix API 公式ドキュメント
🤖 Geminiとのペアプロの進め方
最初はKotlinの構文やAndroid特有の作法に戸惑いました。しかし、やりたいことをすべてGeminiに投げながら進めることで、スムーズに実装できました。
- 実装したい機能を伝えて「そのアプローチで実現可能か」「無料枠に収まるか」などの壁打ちや要件整理を手伝ってもらう。
- 世の中にある既存の帰宅系アプリやiPhoneのショートカットなども検討したが、「知らないサードパーティアプリにLINEのアクセストークンや位置情報を預けるのはプライバシー面で少し不安…」と感じたため、最終的に「自分でソースコードを管理して信頼できる自作アプリを作ろう!」と思い立ちました。
- ライブラリのエラーやビルドエラーが出たら、スタックトレースをそのままGeminiに貼って修正してもらう。
- 「誤送信防止のロジックを追加したい」「クイックリプライを入れたい」と、動くベースを作りながら段階的に機能を拡張していく。
💻 実際のコード(MainActivity.kt)
完成したアプリのメインロジックとなる MainActivity.kt の完全版です。
package com.example.linetestapi
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class MainActivity : ComponentActivity() {
// 各種設定情報
private val lineAccessToken = "YOUR_LINE_ACCESS_TOKEN"
private val targetUserId = "YOUR_TARGET_USER_ID"
private val googleApiKey = "YOUR_GOOGLE_API_KEY"
private val homeAddress = "大阪府..." // ご自宅の住所
private lateinit var fusedLocationClient: FusedLocationProviderClient
data class RouteInfo(val durationSec: Int, val distanceMeters: Int)
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) {
processAndSendHomeNotification()
} else {
Toast.makeText(this, "位置情報の許可が必要です", Toast.LENGTH_SHORT).show()
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
if (hasLocationPermission()) {
processAndSendHomeNotification()
} else {
requestPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
@SuppressLint("MissingPermission")
private fun processAndSendHomeNotification() {
Toast.makeText(this, "現在地を取得中...", Toast.LENGTH_SHORT).show()
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
.addOnSuccessListener { location ->
if (location != null) {
val origin = "${location.latitude},${location.longitude}"
lifecycleScope.launch {
// 1. Google Distance Matrix APIでルート情報を取得
val bicycleRoute = getRouteInfo(origin, homeAddress, "bicycling")
// 2. 誤送信防止:自宅から500m以内の場合はキャンセル
if (bicycleRoute != null && bicycleRoute.distanceMeters <= 500) {
Toast.makeText(this@MainActivity, "自宅から500m以内のため送信キャンセル", Toast.LENGTH_LONG).show()
finish()
return@launch
}
var isBicycle = false
var travelDurationSec = bicycleRoute?.durationSec
// 3. 20分(1200秒)以内なら自転車、超えるなら電車
if (bicycleRoute != null && bicycleRoute.durationSec <= 1200) {
isBicycle = true
} else {
val transitRoute = getRouteInfo(origin, homeAddress, "transit")
travelDurationSec = transitRoute?.durationSec ?: bicycleRoute?.durationSec ?: 1800
isBicycle = false
}
// 4. 到着予定時刻の計算
val arrivalTimeStr = calculateArrivalTime(travelDurationSec ?: 1800)
// 5. メッセージ作成
val messageText = if (isBicycle) {
"いまから帰るね🚴♂️ \n$arrivalTimeStr くらいに到着予定です"
} else {
"いまから帰るよ🚃 \n$arrivalTimeStr くらいに到着予定です"
}
// 6. LINE送信(クイックリプライ付き)
val success = sendLineMessage(messageText)
if (success) {
Toast.makeText(this@MainActivity, "LINE送信完了!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, "送信失敗", Toast.LENGTH_LONG).show()
}
finish()
}
} else {
Toast.makeText(this, "現在地が取得できませんでした", Toast.LENGTH_SHORT).show()
finish()
}
}
.addOnFailureListener {
Toast.makeText(this, "GPSエラーが発生しました", Toast.LENGTH_SHORT).show()
finish()
}
}
private suspend fun getRouteInfo(origin: String, destination: String, mode: String): RouteInfo? = withContext(Dispatchers.IO) {
val client = OkHttpClient()
val encodedDest = URLEncoder.encode(destination, "UTF-8")
val url = "[https://maps.googleapis.com/maps/api/distancematrix/json?origins=$origin&destinations=$encodedDest&mode=$mode&key=$googleApiKey](https://maps.googleapis.com/maps/api/distancematrix/json?origins=$origin&destinations=$encodedDest&mode=$mode&key=$googleApiKey)"
val request = Request.Builder().url(url).build()
return@withContext try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val jsonStr = response.body?.string() ?: ""
val json = JSONObject(jsonStr)
val rows = json.getJSONArray("rows")
if (rows.length() > 0) {
val elements = rows.getJSONObject(0).getJSONArray("elements")
if (elements.length() > 0) {
val element = elements.getJSONObject(0)
if (element.getString("status") == "OK") {
val durationSec = element.getJSONObject("duration").getInt("value")
val distanceMeters = element.getJSONObject("distance").getInt("value")
return@withContext RouteInfo(durationSec, distanceMeters)
}
}
}
}
null
}
} catch (e: Exception) {
Log.e("GOOGLE_API", "API取得エラー", e)
null
}
}
private fun calculateArrivalTime(durationSeconds: Int): String {
val calendar = Calendar.getInstance()
calendar.add(Calendar.SECOND, durationSeconds)
val sdf = SimpleDateFormat("HH:mm", Locale.JAPAN)
return sdf.format(calendar.time)
}
private suspend fun sendLineMessage(messageText: String): Boolean = withContext(Dispatchers.IO) {
val client = OkHttpClient()
// クイックリプライボタンの定義
val quickReplyItems = JSONArray().apply {
put(JSONObject().apply {
put("type", "action")
put("action", JSONObject().apply {
put("type", "message")
put("label", "👍 了解")
put("text", "👍")
})
})
put(JSONObject().apply {
put("type", "action")
put("action", JSONObject().apply {
put("type", "message")
put("label", "気をつけて")
put("text", "気を付けてね")
})
})
put(JSONObject().apply {
put("type", "action")
put("action", JSONObject().apply {
put("type", "message")
put("label", "🍺 お酒買って来て")
put("text", "お酒を買って来て")
})
})
put(JSONObject().apply {
put("type", "action")
put("action", JSONObject().apply {
put("type", "message")
put("label", "label", "🍞 パン買って来て")
put("text", "パン買って来て")
})
})
}
val jsonBody = JSONObject().apply {
put("to", targetUserId)
put("messages", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", messageText)
put("quickReply", JSONObject().apply {
put("items", quickReplyItems)
})
})
})
}
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonBody.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url("[https://api.line.me/v2/bot/message/push](https://api.line.me/v2/bot/message/push)")
.post(body)
.addHeader("Authorization", "Bearer $lineAccessToken")
.addHeader("Content-Type", "application/json")
.build()
return@withContext try {
client.newCall(request).execute().use { response ->
response.isSuccessful
}
} catch (e: Exception) {
Log.e("LINE_DEBUG", "送信エラー", e)
false
}
}
}
💡 こだわりのポイント
1. 自宅500m以内の誤送信防止
家の中でアイコンをタップしてしまったり、出発直後に誤作動が起きても、距離が500m以内であれば自動でキャンセルされるため安心です。
2. LINEのクイックリプライで家族もワンタップ返信
届いたメッセージの下に「了解」「お酒買ってきて」などのボタンが表示されるため、受け取った家族側もワンタップで返事ができるようになっています。
おわりに
Geminiを相棒にすることで、Androidアプリを作り上げることができました。AIのコード生成や解説は、新しい言語に挑戦するハードルを劇的に下げてくれます。ぜひGeminiと一緒に新しい言語やプラットフォームでの開発に挑戦してみてください!

