「LINEには写真、SMSにはテキスト」をiOS/Androidで実装する:UIActivityItemSource vs Intent.setPackage() で読む設計思想の違い

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 の直接共有機能の要件は次のとおりです。

共有先iOSAndroid
LINEテキストのみ写真+テキスト
WhatsAppテキストのみ写真+テキスト
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を返します。

tempFileURLlazy 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)

共有先ConditionalImageSourceNamedTextSource結果
LINEnil(抑制)プレーンテキストテキストのみ ✅
WhatsAppnil(抑制)プレーンテキストテキストのみ ✅
Signal・メール画像プレーンテキスト画像+テキスト ✅
iMessage画像プレーンテキスト画像+テキスト ✅
コピー画像プレーンテキストテキストがクリップボードへ ✅
AirDrop画像命名済み .txt URL画像+命名済みTXT ✅
ファイルに保存画像命名済み .txt URL命名済みTXT ✅

3.7.iOSのポイントまとめ

  • 共有先アプリを事前指定しない。ユーザーが共有シートから選ぶ
  • activityItems に複数オブジェクトを渡すと、受け取り側が処理できる型だけを使う
  • LINE/WhatsApp の画像問題は nil を返すことで解決。ConditionalImageSource というクラス名を言えるがその意図を表現している
  • ファイル名を制御したいときは URL を返す。これが「ファイルに保存」でのファイル名制御の解決策
  • lazy varfileURL をキャッシュすると、複数回呼ばれても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_SENDimage/* + 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 は位置情報・写真・メモを記録・共有できるアプリです。

← ITQ Lab トップに戻る