1.はじめに
自社アプリ ITQ GNSS Logger v1.7 に「直接共有」機能を実装したとき、あることに気づきました。
同じユーザー体験を実現するのに、iOSとAndroidでは設計思想が根本から違う。
ユーザーが「LINEで送る」「メールに添付する」「SMSで位置情報を送る」という操作をするとき、アプリ側は共有先ごとに渡す内容を変える必要があります。LINEには写真+テキスト、SMSにはGoogle マップURLのみ、ファイル保存には命名済みTXTファイル——というように。
この「出し分け」を実現するアーキテクチャが、iOSとAndroidで驚くほど異なります。本記事ではその実装の違いと設計思想を、実際のプロダクションコードをもとに解説します。
:::note info
本記事のコードは VisitDetailView(訪問履歴詳細画面)の実装をベースにしています。ユニットテスト(V17BugfixTests.swift)で主要シナリオを確認済み、ITQ GNSS Logger v1.7としてリリース済みです。
:::
2.実現したいユーザー体験
ITQ GNSS Logger v1.7 の直接共有機能の要件は次のとおりです。
| 共有先 | iOS | Android |
|---|---|---|
| LINE | テキストのみ | 写真+テキスト |
| テキストのみ | 写真+テキスト | |
| Signal | 写真+テキスト | 写真+テキスト |
| メール | 写真+テキスト | 写真+テキスト |
| SMS / iMessage | テキストのみ(Google マップURL) | テキストのみ |
| AirDrop | 写真+命名済みTXT | — |
| ファイルに保存 | 命名済みTXT | — |
| Google Drive | — | 命名済みTXT |
要点:iOSの LINE に写真を渡すとテキスト本文が添付扱いになるというバグがあるため、iOSでは LINE/WhatsApp にテキストのみを渡す定義にしています。
3.iOS実装編(Swift / UIKit)
3.1.設計思想:「送り手がコントロール」
iOSの共有は UIActivityViewController を使います。activityItems に渡すオブジェクトに UIActivityItemSource を実装すると、共有シートが各アプリにアイテムを渡す直前に itemForActivityType(_:) を呼んでくれます。
ポイント:共有先アプリを事前に指定する必要はありません。ユーザーが共有シートからアプリを選んだ瞬間に、送り手(アプリ)側がアイテムを決定します。
3.2.処理フロー(iOS)
flowchart TD A[共有ボタンをタップ] —> B{写真あり?} B — あり —> C[“activityItems =n[ConditionalImageSource, NamedTextSource]”] B — なし —> D[“activityItems = [NamedTextSource]”] C —> E[UIActivityViewController 表示] D —> E E —> F[ユーザーがアプリを選択] F —> G[“itemForActivityType(_:) 呼び出し”] G —> H1{ConditionalImageSource} G —> H2{NamedTextSource} H1 — LINE / WhatsApp —> I1[nil を返す] H1 — それ以外 —> I2[image を返す] H2 — “コピー/メール/iMessagen/LINE/WhatsApp/Twitter/Instagram” —> J1[text を返す] H2 — AirDrop/ファイルに保存 —> J2[fileURL を返す] I1 —> K1[テキストのみ送信] I2 —> K2[画像+テキスト送信] J1 —> K1 J2 —> K2
flowchart TD
A[共有ボタンをタップ] --> B{写真あり?}
B -- あり --> C["activityItems =n[ConditionalImageSource, NamedTextSource]"]
B -- なし --> D["activityItems = [NamedTextSource]"]
C --> E[UIActivityViewController 表示]
D --> E
E --> F[ユーザーがアプリを選択]
F --> G["itemForActivityType(_:) 呼び出し"]
G --> H1{ConditionalImageSource}
G --> H2{NamedTextSource}
H1 -- LINE / WhatsApp --> I1[nil を返す]
H1 -- それ以外 --> I2[image を返す]
H2 -- "コピー/メール/iMessagen/LINE/WhatsApp/Twitter/Instagram" --> J1[text を返す]
H2 -- AirDrop/ファイルに保存 --> J2[fileURL を返す]
I1 --> K1[テキストのみ送信]
I2 --> K2[画像+テキスト送信]
J1 --> K1
J2 --> K2
3.3.UIActivityItemSource を2クラスに分離する
「画像担当」と「テキスト担当」を別クラスに分離し、両方を同時に activityItems に渡します。
var items: [Any] = [NamedTextSource(text: text, fileName: fileName)]
if let image = firstPhoto {
items.insert(ConditionalImageSource(image), at: 0)
}
let vc = UIActivityViewController(activityItems: items, applicationActivities: nil)
present(vc, animated: true)
var items: [Any] = [NamedTextSource(text: text, fileName: fileName)]
if let image = firstPhoto {
items.insert(ConditionalImageSource(image), at: 0)
}
let vc = UIActivityViewController(activityItems: items, applicationActivities: nil)
present(vc, animated: true)
3.4.ConditionalImageSource(画像担当)
LINE・WhatsApp は EXTRA_TEXT と画像ファイルを同時に受け取るとテキスト本文ではなくファイル添付として扱ってしまいます。このアプリには nil を返して画像を抑制します。
final class ConditionalImageSource: NSObject, UIActivityItemSource {
let image: UIImage
/// 画像+テキストの同時共有を処理できないアプリの識別子キーワード
private static let textOnlyApps = [“line”, “whatsapp”]
init(_ image: UIImage) { self.image = image }
func activityViewControllerPlaceholderItem(
_ activityViewController: UIActivityViewController
) -> Any {
image
}
func activityViewController(
_ activityViewController: UIActivityViewController,
itemForActivityType activityType: UIActivity.ActivityType?
) -> Any? {
guard let type = activityType else { return image }
let id = type.rawValue.lowercased()
// LINE(jp.naver.line.Share)・WhatsApp(net.whatsapp.WhatsApp.ShareExtension)
// は activityType の rawValue に “line” / “whatsapp” を含む → nil で画像を抑制
if Self.textOnlyApps.contains(where: { id.contains($0) }) { return nil }
return image
}
}
final class ConditionalImageSource: NSObject, UIActivityItemSource {
let image: UIImage
/// 画像+テキストの同時共有を処理できないアプリの識別子キーワード
private static let textOnlyApps = ["line", "whatsapp"]
init(_ image: UIImage) { self.image = image }
func activityViewControllerPlaceholderItem(
_ activityViewController: UIActivityViewController
) -> Any {
image
}
func activityViewController(
_ activityViewController: UIActivityViewController,
itemForActivityType activityType: UIActivity.ActivityType?
) -> Any? {
guard let type = activityType else { return image }
let id = type.rawValue.lowercased()
// LINE(jp.naver.line.Share)・WhatsApp(net.whatsapp.WhatsApp.ShareExtension)
// は activityType の rawValue に "line" / "whatsapp" を含む → nil で画像を抑制
if Self.textOnlyApps.contains(where: { id.contains($0) }) { return nil }
return image
}
}
3.5.NamedTextSource(テキスト担当)
テキスト系アプリにはプレーンテキストを、ファイル保存・AirDrop には「地点名_日時.txt」という命名済みファイルURLを返します。
tempFileURL は lazy var にすることで、複数回呼ばれてもファイル書き込みが1回で済みます。
final class NamedTextSource: NSObject, UIActivityItemSource {
let text: String
let fileName: String
init(text: String, fileName: String) {
self.text = text
self.fileName = fileName
}
func activityViewControllerPlaceholderItem(
_ activityViewController: UIActivityViewController
) -> Any { text }
func activityViewController(
_ activityViewController: UIActivityViewController,
itemForActivityType activityType: UIActivity.ActivityType?
) -> Any? {
guard let type = activityType else { return fileURL ?? text }
let id = type.rawValue.lowercased()
// コピー・メール・iMessage はプレーンテキスト
let textTypes: [UIActivity.ActivityType] = [.copyToPasteboard, .mail, .message]
if textTypes.contains(type) { return text }
// LINE・WhatsApp・Twitter(X)・Instagram もプレーンテキスト
if [“line”, “whatsapp”, “twitter”, “instagram”].contains(where: { id.contains($0) }) {
return text
}
// AirDrop・ファイルに保存等には命名済みURLを渡す(ファイル名が適用される)
return fileURL ?? text
}
/// lazy で一度だけ書き込む(UTF-8 BOM付き、Excelでの文字化け防止)
private lazy var fileURL: URL? = {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
guard let body = [text.data](http://text.data)(using: .utf8) else { return nil }
let data = Data([0xEF, 0xBB, 0xBF]) + body
try? data.write(to: url, options: .atomic)
return url
}()
}
final class NamedTextSource: NSObject, UIActivityItemSource {
let text: String
let fileName: String
init(text: String, fileName: String) {
self.text = text
self.fileName = fileName
}
func activityViewControllerPlaceholderItem(
_ activityViewController: UIActivityViewController
) -> Any { text }
func activityViewController(
_ activityViewController: UIActivityViewController,
itemForActivityType activityType: UIActivity.ActivityType?
) -> Any? {
guard let type = activityType else { return fileURL ?? text }
let id = type.rawValue.lowercased()
// コピー・メール・iMessage はプレーンテキスト
let textTypes: [UIActivity.ActivityType] = [.copyToPasteboard, .mail, .message]
if textTypes.contains(type) { return text }
// LINE・WhatsApp・Twitter(X)・Instagram もプレーンテキスト
if ["line", "whatsapp", "twitter", "instagram"].contains(where: { id.contains($0) }) {
return text
}
// AirDrop・ファイルに保存等には命名済みURLを渡す(ファイル名が適用される)
return fileURL ?? text
}
/// lazy で一度だけ書き込む(UTF-8 BOM付き、Excelでの文字化け防止)
private lazy var fileURL: URL? = {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
guard let body = [text.data](http://text.data)(using: .utf8) else { return nil }
let data = Data([0xEF, 0xBB, 0xBF]) + body
try? data.write(to: url, options: .atomic)
return url
}()
}
3.6.アプリごとの動作まとめ(iOS)
| 共有先 | ConditionalImageSource | NamedTextSource | 結果 |
|---|---|---|---|
| LINE | nil(抑制) | プレーンテキスト | テキストのみ ✅ |
nil(抑制) | プレーンテキスト | テキストのみ ✅ | |
| Signal・メール | 画像 | プレーンテキスト | 画像+テキスト ✅ |
| iMessage | 画像 | プレーンテキスト | 画像+テキスト ✅ |
| コピー | 画像 | プレーンテキスト | テキストがクリップボードへ ✅ |
| AirDrop | 画像 | 命名済み .txt URL | 画像+命名済みTXT ✅ |
| ファイルに保存 | 画像 | 命名済み .txt URL | 命名済みTXT ✅ |
3.7.iOSのポイントまとめ
- 共有先アプリを事前指定しない。ユーザーが共有シートから選ぶ
activityItemsに複数オブジェクトを渡すと、受け取り側が処理できる型だけを使う- LINE/WhatsApp の画像問題は
nilを返すことで解決。ConditionalImageSourceというクラス名を言えるがその意図を表現している - ファイル名を制御したいときは URL を返す。これが「ファイルに保存」でのファイル名制御の解決策
lazy varでfileURLをキャッシュすると、複数回呼ばれても1回しか書き込まない
4.Android実装編(Kotlin)
4.1.設計思想:「宛先を直接指定」
Androidでは、共有先アプリのパッケージ名を setPackage() で直接指定して Intent を発行します。iOSのように「共有シートに渡してOSに任せる」ではなく、「どのアプリに送るか」をコード側が明示します。
4.2.処理フロー(Android)
flowchart TD
A[共有ボタンをタップ] —> B{共有先アプリ}
B — LINE / WhatsApp / Signal —> C[shareToPackage]
B — メール —> D[shareViaEmail]
B — SMS —> E[shareViaSms]
B — Google Drive —> F[shareToGoogleDrive]
C —> G{写真あり?}
G — あり —> H[“photoPathToUri()ncontent:// → FileProvider URI”]
G — なし —> I[“type=text/plainnEXTRA_TEXT のみ”]
H —> J[“type=image/*nEXTRA_STREAM + EXTRA_TEXTnClipData(Android 10+必須)nsetPackage( パッケージ名 )”]
J —> P{resolveActivity?}
I —> P
P — null —> Q[サイレント終了]
P — not null —> R[startActivity]
D —> K{写真あり?}
K — あり —> L[“type=image/jpeg + ClipDatancreateChooser”]
K — なし —> M[“type=text/plainncreateChooser”]
L —> S[startActivity]
M —> S
E —> N[“ACTION_SENDTOnsmsto: スキームn※ setPackage 不使用”]
N —> T[startActivity]
F —> O[“BOM付きTXTファイル生成nFileProvider URInsetPackage([com.google.android.apps.docs](http://com.google.android.apps.docs))”\]
O —> P2{resolveActivity?}
P2 — null —> Q2[サイレント終了]
P2 — not null —> R2[startActivity]
flowchart TD
A[共有ボタンをタップ] --> B{共有先アプリ}
B -- LINE / WhatsApp / Signal --> C[shareToPackage]
B -- メール --> D[shareViaEmail]
B -- SMS --> E[shareViaSms]
B -- Google Drive --> F[shareToGoogleDrive]
C --> G{写真あり?}
G -- あり --> H["photoPathToUri()ncontent:// → FileProvider URI"]
G -- なし --> I["type=text/plainnEXTRA_TEXT のみ"]
H --> J["type=image/*nEXTRA_STREAM + EXTRA_TEXTnClipData(Android 10+必須)nsetPackage( パッケージ名 )"]
J --> P{resolveActivity?}
I --> P
P -- null --> Q[サイレント終了]
P -- not null --> R[startActivity]
D --> K{写真あり?}
K -- あり --> L["type=image/jpeg + ClipDatancreateChooser"]
K -- なし --> M["type=text/plainncreateChooser"]
L --> S[startActivity]
M --> S
E --> N["ACTION_SENDTOnsmsto: スキームn※ setPackage 不使用"]
N --> T[startActivity]
F --> O["BOM付きTXTファイル生成nFileProvider URInsetPackage([com.google.android.apps.docs](http://com.google.android.apps.docs))"]
O --> P2{resolveActivity?}
P2 -- null --> Q2[サイレント終了]
P2 -- not null --> R2[startActivity]
4.3.photoPathToUri():FileProvider URI への変換が必須
:::note warn
おしいポイント: ギャラリーから取得した content:// URI や内部ストレージのパスをそのままサードパーティアプリに渡すと権限エラーでクラッシュします。LINE・WhatsApp・Signal などはあなたのアプリの content:// URI へのアクセス権を持っていないためです。
:::
解決策:一度キャッシュにコピーして、自前の FileProvider URI に変換してから渡します。
/*
- content:// URI またはファイルパス → FileProvider 経由の共有可能 Uri
- サードパーティアプリが確実に読み取れるようキャッシュへコピーして返す */
private fun photoPathToUri(path: String): Uri? {
return try { val photoDir = File(context.cacheDir, “photos”).also { it.mkdirs() } val destFile = File(photoDir, “share_${System.currentTimeMillis()}.jpg”) if (path.startsWith(“content://”)) {
context.contentResolver.openInputStream(Uri.parse(path))?.use { input ->
destFile.outputStream().use { input.copyTo(it) }
}
} else {
val src = File(path)
if (src.exists()) src.copyTo(destFile, overwrite = true) else return null
}
if (!destFile.exists() || destFile.length() == 0L) return null
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", destFile)
} catch (_: Exception) { null }
}
/*
- content:// URI またはファイルパス → FileProvider 経由の共有可能 Uri
- サードパーティアプリが確実に読み取れるようキャッシュへコピーして返す
*/
private fun photoPathToUri(path: String): Uri? {
return try {
val photoDir = File(context.cacheDir, "photos").also { it.mkdirs() }
val destFile = File(photoDir, "share_${System.currentTimeMillis()}.jpg")
if (path.startsWith("content://")) {
context.contentResolver.openInputStream(Uri.parse(path))?.use { input ->
destFile.outputStream().use { input.copyTo(it) }
}
} else {
val src = File(path)
if (src.exists()) src.copyTo(destFile, overwrite = true) else return null
}
if (!destFile.exists() || destFile.length() == 0L) return null
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", destFile)
} catch (_: Exception) { null }
}
4.4.shareToPackage():LINE / WhatsApp / Signal 共通処理
/*
- LINE・WhatsApp・Signal 共通: setPackage でアプリを直接指定して送信
- 写真なしの場合は ClipData 不要(URI を渡さないため)
*/
private fun shareToPackage(record: VisitRecordEntity, packageName: String, photoPath: String? = null) {
val photoUri = photoPath?.let { photoPathToUri(it) }
val intent = Intent(Intent.ACTION\_SEND).apply {
if (photoUri != null) {
type = "image/\*"
putExtra(Intent.EXTRA\_STREAM, photoUri)
putExtra(Intent.EXTRA\_TEXT, buildShareText(record))
// Android 10+ では ClipData を明示しないと FileProvider URI の
// パーミッションがサードパーティアプリに伝わらない
clipData = ClipData.newRawUri("", photoUri)
addFlags(Intent.FLAG\_GRANT\_READ\_URI\_PERMISSION)
} else {
type = "text/plain"
putExtra(Intent.EXTRA\_TEXT, buildShareText(record))
}
setPackage(packageName)
addFlags(Intent.FLAG\_ACTIVITY\_NEW\_TASK)
}
// アプリ未インストール時はサイレントに何もしない
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
fun shareToLine(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "\[jp.naver.line.android\](http://jp.naver.line.android)", photoPath)
fun shareToWhatsApp(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "com.whatsapp", photoPath)
fun shareToSignal(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "org.thoughtcrime.securesms", photoPath)
/*
- LINE・WhatsApp・Signal 共通: setPackage でアプリを直接指定して送信
- 写真なしの場合は ClipData 不要(URI を渡さないため)
*/
private fun shareToPackage(record: VisitRecordEntity, packageName: String, photoPath: String? = null) {
val photoUri = photoPath?.let { photoPathToUri(it) }
val intent = Intent(Intent.ACTION_SEND).apply {
if (photoUri != null) {
type = "image/*"
putExtra(Intent.EXTRA_STREAM, photoUri)
putExtra(Intent.EXTRA_TEXT, buildShareText(record))
// Android 10+ では ClipData を明示しないと FileProvider URI の
// パーミッションがサードパーティアプリに伝わらない
clipData = ClipData.newRawUri("", photoUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, buildShareText(record))
}
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// アプリ未インストール時はサイレントに何もしない
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
fun shareToLine(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "[jp.naver.line.android](http://jp.naver.line.android)", photoPath)
fun shareToWhatsApp(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "com.whatsapp", photoPath)
fun shareToSignal(record: VisitRecordEntity, photoPath: String? = null) =
shareToPackage(record, "org.thoughtcrime.securesms", photoPath)
:::note info
iOSとの違い: iOSでは LINE/WhatsApp に写真を渡すとテキスト本文が添付扱いになるため ConditionalImageSource で画像を nil 抑制しました。Android では ACTION_SEND に image/* + EXTRA_TEXT を同時に渡しても正常に処理されるため、この分岐は不要です。
:::
4.5.shareViaEmail():setPackage なし・createChooser で選択
fun shareViaEmail(record: VisitRecordEntity, photoPath: String? = null) {
val photoUri = photoPath?.let { photoPathToUri(it) }
val intent = Intent(Intent.ACTION\_SEND).apply {
if (photoUri != null) {
type = "image/jpeg"
putExtra(Intent.EXTRA\_STREAM, photoUri)
clipData = ClipData.newRawUri("", photoUri)
addFlags(Intent.FLAG\_GRANT\_READ\_URI\_PERMISSION)
} else {
// 写真なしは text/plain で十分。
// 「 message/rfc822 」はメールアプリを引き込む非公式ハックとしてよく見かけるが、
// Outlook 等では挙動不定のため text/plain を推奨
type = "text/plain"
}
putExtra(Intent.EXTRA\_SUBJECT, record.locationName)
putExtra(Intent.EXTRA\_TEXT, buildShareText(record))
addFlags(Intent.FLAG\_ACTIVITY\_NEW\_TASK)
}
context.startActivity(Intent.createChooser(intent, "").apply {
addFlags(Intent.FLAG\_ACTIVITY\_NEW\_TASK)
})
}
fun shareViaEmail(record: VisitRecordEntity, photoPath: String? = null) {
val photoUri = photoPath?.let { photoPathToUri(it) }
val intent = Intent(Intent.ACTION_SEND).apply {
if (photoUri != null) {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, photoUri)
clipData = ClipData.newRawUri("", photoUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
// 写真なしは text/plain で十分。
// 「 message/rfc822 」はメールアプリを引き込む非公式ハックとしてよく見かけるが、
// Outlook 等では挙動不定のため text/plain を推奨
type = "text/plain"
}
putExtra(Intent.EXTRA_SUBJECT, record.locationName)
putExtra(Intent.EXTRA_TEXT, buildShareText(record))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, "").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
4.6.shareViaSms():ACTION_SENDTO + smsto: スキーム
SMS は setPackage() を使いません。理由:デフォルトSMSアプリは機種・キャリアによって異なる(Pixel: com.google.android.apps.messaging、Samsung: com.samsung.android.messaging 等)ため、パッケージ名を固定するとクラッシュします。smsto: スキームでOSがデフォルトSMSアプリを自動選択します。
fun shareViaSms(record: VisitRecordEntity) {
val url = “https://www.google.com/maps?q=${record.latitude},${record.longitude}”
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("smsto:") // setPackage は使わない:デフォルトSMSアプリは機種依存
putExtra("sms\_body", url)
addFlags(Intent.FLAG\_ACTIVITY\_NEW\_TASK)
}
context.startActivity(intent)
}
fun shareViaSms(record: VisitRecordEntity) {
val url = "https://www.google.com/maps?q=${record.latitude},${record.longitude}"
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("smsto:") // setPackage は使わない:デフォルトSMSアプリは機種依存
putExtra("sms_body", url)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
4.7.shareToGoogleDrive():ACTION_SEND + setPackage でTXTファイルを直接送信
ACTION_CREATE_DOCUMENT を使うと「保存先フォルダ選択UI」が表示されてシームレスさが失われます。ACTION_SEND + setPackage でGoogle Driveアプリに直接送り込む方がUXが良いです。
fun shareToGoogleDrive(record: VisitRecordEntity) {
val exportDir = File(context.cacheDir, “exports”).also { it.mkdirs() }
val sdf = SimpleDateFormat(“yyyyMMdd_HHmmss”, Locale.getDefault())
val ts = sdf.format(Date(record.visitedAt))
val safeName = record.locationName
.replace(Regex(”[/\\:*?”<>|]”), ”_“).ifBlank { “visit” }
val file = File(exportDir, ”${safeName}_${ts}.txt”)
writeWithBOM(file, buildShareText(record)) // UTF-8 BOM付き
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA\_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
setPackage("\[com.google.android.apps.docs\](http://com.google.android.apps.docs)")
addFlags(Intent.FLAG\_GRANT\_READ\_URI\_PERMISSION)
addFlags(Intent.FLAG\_ACTIVITY\_NEW\_TASK)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
fun shareToGoogleDrive(record: VisitRecordEntity) {
val exportDir = File(context.cacheDir, "exports").also { it.mkdirs() }
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
val ts = sdf.format(Date(record.visitedAt))
val safeName = record.locationName
.replace(Regex("[/\\:*?"<>|]"), "_").ifBlank { "visit" }
val file = File(exportDir, "${safeName}_${ts}.txt")
writeWithBOM(file, buildShareText(record)) // UTF-8 BOM付き
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
setPackage("[com.google.android.apps.docs](http://com.google.android.apps.docs)")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
4.8.isAppInstalled():Android 13(API 33)以降対応版
/* Android 13+ は PackageInfoFlags API を使う(旧 API は API 33 以降で deprecated) */
fun isAppInstalled(packageName: String): Boolean = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(0)
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(packageName, 0)
}
true
} catch (_: PackageManager.NameNotFoundException) { false }
/* Android 13+ は PackageInfoFlags API を使う(旧 API は API 33 以降で deprecated) */
fun isAppInstalled(packageName: String): Boolean = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(0)
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(packageName, 0)
}
true
} catch (_: PackageManager.NameNotFoundException) { false }
4,9.AndroidManifest.xml — \ 宣言(Android 11以降必須)
Android 11(API 30)以降、isAppInstalled() や resolveActivity() が正常に機能するには <queries> の宣言が必要です。たった1行忘れるだけで常に未インストール判定になります。
<queries>
<package android:name="[jp.naver.line.android](http://jp.naver.line.android)" /> <!-- LINE -->
<package android:name="com.whatsapp" /> <!-- WhatsApp -->
<package android:name="org.thoughtcrime.securesms" /> <!-- Signal -->
<package android:name="[com.google.android.apps.docs](http://com.google.android.apps.docs)" /> <!-- Google Drive -->
</queries>
4.10.file_paths.xml — FileProvider のパス設定
<paths>
<cache-path name="exports" path="exports/" /> <!-- shareToGoogleDrive() のエクスポート先 -->
<cache-path name="photos" path="photos/" /> <!-- photoPathToUri() の一時コピー先 -->
<files-path name="visit_photos" path="visit_photos/" />
</paths>
4.11.Androidのポイントまとめ
- パッケージ名を直接知っている必要がある(LINE/WhatsApp/Signal/Google Drive)
- メール・SMSは
setPackage()を使わずシステムに委ねる - Android 10+ は
ClipDataを明示しないと FileProvider URI のパーミッションが第三者アプリに伝わらない content://URI は必ずphotoPathToUri()で自前の FileProvider URI に変換してから渡す- Google Drive は
ACTION_CREATE_DOCUMENTよりACTION_SEND + setPackageの方がシームレスなUX <queries>の宣言を忘れるとresolveActivity()が Android 11+ で常に null になるisAppInstalled()は API 33+ 対応版を使う
5.設計思想の違いを読む
| iOS(UIActivityItemSource) | Android(Intent + setPackage) | |
|---|---|---|
| 共有先の指定 | しない(ユーザーが選ぶ) | パッケージ名で直接指定 |
| 内容の切り替え | activityType で判定してアプリ側が制御 | Intent を宛先ごとに個別生成 |
| 拡張性 | 新しいアプリが増えても自動対応 | パッケージ名を追加定義が必要 |
| UX | 共有シートUI(統一感あり) | 直接アプリ起動(シームレス) |
| 実装コスト | プロトコル実装1回で全アプリ対応 | アプリ数分のIntentを管理 |
iOSの哲学は**「送り手がコントロール」**。共有シートは「どのアプリにどんな形式で渡すか」を送り手側が itemForActivityType で決定します。新しいアプリが登場しても、既存ロジックで動くケースが多い。
Androidの哲学は**「宛先を直接指定」**。Intent は手紙の封筒に相当し、宛名(パッケージ名)を書いてOSに投函するイメージです。制御が明確な反面、新しいアプリへの対応はコード変更が必要です。
どちらが優れているという話ではなく、それぞれのOSが選択したトレードオフです。
6.おわりに
ITQ GNSS Logger v1.7 でこの実装を完成させた結果、ユーザーは訪問記録をLINE・WhatsApp・メール・SMSなど好みのアプリに、最適な形式でシームレスに共有できるようになりました。
クロスプラットフォーム開発をしていると「iOSでできることはAndroidでもできる」と思いがちですが、その実現方法は設計思想レベルで異なることがあります。本記事がその違いを理解する一助になれば幸いです。
ITQ GNSS Logger は位置情報・写真・メモを記録・共有できるアプリです。
- iOS版:App Store「ITQ GNSS Logger」
- Android版:Google Play「ITQ GNSS Logger」