0
0

More than 1 year has passed since last update.

ComposeのNavigationを使ってShareTargetアプリ(他アプリから共有情報を受信するアプリ)にする

Posted at

この記事の内容

他のアプリ(Chromeとかマップとか)で共有を選んだときに、自分のアプリでその情報を受信するアプリを作ります。
Compose, Navigationを使っていきます。

前提

NavigationとComposeの依存関係を抜粋しました。

app/build.gradle
    // compose
    val composeVersion = "1.4.3"
    implementation("androidx.activity:activity-compose:1.7.2")
    implementation("androidx.compose.ui:ui:$composeVersion")
    implementation("androidx.compose.ui:ui-graphics:$composeVersion")
    implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
    implementation("androidx.compose.foundation:foundation:$composeVersion")
    implementation("androidx.compose.material:material-icons-extended:$composeVersion")
    implementation("androidx.compose.material3:material3:1.1.1")
    // depend on it to compile against version 34 or later of the Android APIs.となるので、Gradleが34対応ができるまでは古いバージョンを使う。
    // implementation("androidx.navigation:navigation-compose:2.7.0-rc01")
    //noinspection GradleDependency
    implementation("androidx.navigation:navigation-compose:2.7.0-beta01")
gradleの全文が気になる方は展開してください
build.gradle
plugins {
    id("com.android.application") version "8.1.0" apply false
    id("org.jetbrains.kotlin.android") version "1.8.10" apply false
    id("com.google.gms.google-services") version "4.3.15" apply false
    id("com.google.dagger.hilt.android") version "2.47" apply false
    id("com.google.devtools.ksp") version "1.8.10-1.0.9" apply false
}
``` 
```gradle:app/build.gradle
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.devtools.ksp")
    id("com.google.gms.google-services")
    id("com.google.dagger.hilt.android")
}

android {
    namespace = "com.jozu.compose.planfun"
    compileSdk = 33

    val localProperties = gradleLocalProperties(rootDir)
    val googleOauthServerClientId = localProperties.getProperty("google_oauth_server_client_id")

    buildFeatures {
        buildConfig = true
    }
    defaultConfig {
        applicationId = "com.jozu.compose.planfun"
        minSdk = 26
        // AGP roadmap https://developer.android.com/studio/releases/gradle-plugin-roadmap?hl=ja
        //noinspection OldTargetApi
        targetSdk = 33
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }

        buildConfigField("String", "GOOGLE_OAUTH_SERVER_CLIENT_ID", googleOauthServerClientId)
    }
    signingConfigs {
        getByName("debug") {
            storeFile = file(project.rootProject.file(localProperties.getProperty("keystore.file")))
            storePassword = localProperties.getProperty("keystore.storePassword")
            keyAlias = localProperties.getProperty("keystore.alias")
            keyPassword = localProperties.getProperty("keystore.keyPassword")
        }
        create("release") {
            storeFile = file(project.rootProject.file(localProperties.getProperty("keystore.file")))
            storePassword = localProperties.getProperty("keystore.storePassword")
            keyAlias = localProperties.getProperty("keystore.alias")
            keyPassword = localProperties.getProperty("keystore.keyPassword")
        }
    }
    buildTypes {
        debug {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
            signingConfig = signingConfigs.getByName("debug")
        }
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
            signingConfig = signingConfigs.getByName("release")
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_17.majorVersion
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.3"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
    implementation("com.google.android.material:material:1.9.0")

    // compose
    val composeVersion = "1.4.3"
    implementation("androidx.activity:activity-compose:1.7.2")
    implementation("androidx.compose.ui:ui:$composeVersion")
    implementation("androidx.compose.ui:ui-graphics:$composeVersion")
    implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
    implementation("androidx.compose.foundation:foundation:$composeVersion")
    implementation("androidx.compose.material:material-icons-extended:$composeVersion")
    implementation("androidx.compose.material3:material3:1.1.1")
    // depend on it to compile against version 34 or later of the Android APIs.となるので、Gradleが34対応ができるまでは古いバージョンを使う。
    // implementation("androidx.navigation:navigation-compose:2.7.0-rc01")
    //noinspection GradleDependency
    implementation("androidx.navigation:navigation-compose:2.7.0-beta01")
    // permission
    implementation("com.google.accompanist:accompanist-permissions:0.30.1")

    // coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2")
    runtimeOnly("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")

    // logger
    implementation("com.jakewharton.timber:timber:5.0.1")

    // hilt
    val hiltVersion = "2.47"
    implementation("com.google.dagger:hilt-android:$hiltVersion")
    ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
    implementation("androidx.hilt:hilt-navigation-compose:1.0.0")

    // Firebase
    implementation(platform("com.google.firebase:firebase-bom:32.2.0"))
    implementation("com.google.firebase:firebase-analytics-ktx")
    implementation("com.google.firebase:firebase-auth-ktx")
    implementation("com.google.firebase:firebase-firestore-ktx")
    implementation("com.google.firebase:firebase-storage-ktx")

    // for GoogleSignIn
    implementation("com.google.android.gms:play-services-auth:20.6.0")

    // maps
    implementation("com.google.maps.android:maps-compose:2.11.4")
    implementation("com.google.android.gms:play-services-maps:18.1.0")

    // coil
    implementation("io.coil-kt:coil-compose:2.4.0")

    // For Robolectric tests.
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.robolectric:robolectric:4.9")
    testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0")

    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion")

    debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")
    debugImplementation("androidx.compose.ui:ui-test-manifest:$composeVersion")
}

モジュール構成

UI部分でのモジュール構成を以下の様に改造していきます。

改造前

クラス・compose 役割
MainActivity PlanFunAppコンポーズをsetContentしているだけのActivityクラス
PlanFunApp ログイン、コンテンツ、Loadingなんかのメインコンテンツをしています。

改造後

クラス・compose 役割 改造?
MainActivity PlanFunAppコンポーズをsetContentしているだけのActivityクラス 改造なし
PlanFunApp メインコンテンツと共有画面を切り替えるNavHostを表示する様にします 改造あり
AppMainScreen メインコンテンツを表示するスクリーン NEW
ShareTargetScreen 共有画面を表示するスクリーン NEW
AppNavHost NavHostの実装 NEW

AndroidManifest

他のアプリで表示する共有シートに、自分のアプリが表示されるようにAndroidManifest.xmlに定義を追加します。他のアプリからMainActivityが呼ばれたいので、「android.intent.action.SEND」をMainActivityのIntentFilterに追加しています。
また、テキストの共有のときだけでいいので、「mimeType」は「"text/*"」としています。
それ以外の共有をしたいときは、公式サイトをご覧くださいませ。

AndroidManifest.xml
        <activity
            android:name=".presentation.MainActivity"
            android:exported="true"
            android:theme="@style/AppTheme"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
+           <intent-filter>
+               <action android:name="android.intent.action.SEND" />
+               <category android:name="android.intent.category.DEFAULT" />
+               <data android:mimeType="text/*" />
+            </intent-filter>
        </activity>

MainActivity

Composeを使用しない場合は、Activityに共有情報の解析に関する実装が必要でした。
Composeを使用する場合は、Activityに何も実装はいらないので、Composeを呼び出すだけの実装です。

MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { PlanFunApp() }
    }
}

PlanFunApp

NavHostをアプリのテーマでラップしただけのComposeです。

PlanFunApp.kt
@Composable
fun PlanFunApp() {
    val navController = rememberNavController()

    PlanFunTheme {
        AppNavHost(navController)
    }
}

AppNavHost

NavHostの定義のなかで、アプリの通常起動か、他アプリからの共有情報を持った起動かの判定をして、表示する画面を変えています。

AppNavHost.kt
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = AppNavRoute.Main.route,
    ) {
        // ①通常起動時
        composable(
            route = AppNavRoute.Main.route,
        ) {
            AppMainScreen()
        }

        // ②共有情報からの起動時
        composable(
            route = AppNavRoute.ShareTarget.route,
            deepLinks = listOf(
                navDeepLink {
                    action = Intent.ACTION_SEND
                    mimeType = "text/*"
                },
            ),
        ) {
            ShareTargetScreen()
        }
    }
}

①通常起動時
こちらは、メインコンテンツを表示するための記述になります。

②共有情報からの起動時
こちらが、他アプリから共有された時の起動になります。

navDeepLink {
action = Intent.ACTION_SEND
mimeType = "text/*"
}

共有情報はテキストのみを想定しているので、navDeepLinkは一つしかリストに入れていませんが、
テキストの他にも必要な場合は、この記述を2つ、3つとリストに入れていく形になります。

この通りに実装した場合、アプリが起動したあとNavigationのBackStackには、「AppNavRoute.Main.route」と「AppNavRoute.ShareTarget.route」の2つが積まれる状態になります。
つまり、共有情報を受けて起動した「ShareTargetScreen」で「戻る」処理が行われた時、アプリが終了するのではなく、「AppMainScreen」が起動します。

共有情報を受信するアプリを作る場合、共有情報の画面から戻る先は、共有情報を提供してきたアプリ(呼び出し元アプリ)が一般的ですので、この実装は私的にはかなり気持ち悪い感じがしました。

戻るボタンに対応したAppNavHost

上記の気持ち悪さを解消するために、NavHostを改造します。
よくあるNavigationを使用してログインとメインコンテンツの画面遷移があるようなアプリ構成では、画面遷移のイベント発生時に、ログインをBackStackから消して、メインコンテンツを表示するという処理をすると思います。

こんな感じですかね。(NavHostにrouteが指定されている前提で)

navController.navigate("遷移先ルート名") {
popUpTo(navController.graph.id) {
inclusive = true
}
}

ただ、今回のACTION_SENDに対応するnavigate()は、ライブラリ内で行われてしまっているので「popUpTo」のところを記述する隙がありません。。。苦肉の策で以下の様に実装してみました。(良案教えてください(・・)(..))

AppNavHost.kt
@Composable
fun AppNavHost(navController: NavHostController) {
+   navController.addOnDestinationChangedListener { controller, destination, arguments ->
+       // ShareTargetScreenへの遷移か?
+       if (destination.route == AppNavRoute.ShareTarget.route) {
+           // 遷移元はAppMainScreenか?
+           if (controller.previousBackStackEntry?.destination?.route == AppNavRoute.Main.route) {
+               // ShareTargetScreenへの遷移に対するResourceIdを取得する
+               val routeId = controller.graph.findNode(AppNavRoute.ShareTarget.route)?.id ?: return@addOnDestinationChangedListener
+               
+               // BackStackをNavHostのルートまで戻す
+               val navOptions = navOptions {
+                   popUpTo(navController.graph.id) {
+                       inclusive = true
+                   }
+               }
+               
+               // ShareTargetScreenへACTION_SENDとかの呼び出し引数をつけた状態で遷移する
+               controller.navigate(
+                   resId = routeId,
+                   args = arguments,
+                   navOptions = navOptions,
+               )
+           }
+       }
+   }

    NavHost(
        navController = navController,
        startDestination = AppNavRoute.Main.route,
+       route = "app-nav",
    ) {
        composable(
            route = AppNavRoute.Main.route,
        ) {
            AppMainScreen()
        }

        composable(
            route = AppNavRoute.ShareTarget.route,
            deepLinks = listOf(
                navDeepLink {
                    action = Intent.ACTION_SEND
                    mimeType = "text/*"
                },
            ),
        ) {
            ShareTargetScreen()
        }
    }
}

「NavHostController」に「OnDestinationChangedListener」を追加して、「AppMainScreen」から「ShareTargetScreen」の遷移の場合は、BackStackを「NavHostのルート」まで戻した後に、改めて「ShareTargetScreen」へ遷移するという記述を足しています。

ShareTargetScreen

起動時引数(共有情報の取得)はViewModelで行いますので、とりあえず表示だけです。

SharedContent.kt
sealed class SharedContent<String> {
    object Proceeding : SharedContent<String>()
    data class Analyzed(val result: String) : SharedContent<String>()
    data class Error(val cause: Throwable) : SharedContent<String>()
}
ShareTargetScreen.kt
@Composable
fun ShareTargetScreen(viewModel: ShareTargetViewModel = hiltViewModel()) {
    // ViewModelから共有情報を取得する
    val sharedContentState: State<SharedContent<String>> = viewModel.sharedContentState.collectAsState()

    when (val sharedContent = sharedContentState.value) {
        is SharedContent.Proceeding -> {
            Text("Shared-Target Proceeding...")
        }

        is SharedContent.Analyzed -> {
            Text("Analyzed!! ${sharedContent.result}")
        }

        is SharedContent.Error -> {
            Text("Error!! ${sharedContent.cause.localizedMessage}")
        }
    }
}

ShareTargetViewModel

ShareTargetViewModel.kt
@HiltViewModel
class ShareTargetViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    sharedAnalyzeUseCase: SharedAnalyzeUseCase,
) : ViewModel() {
    val sharedContentState: StateFlow<SharedContent<String>> = savedStateHandle.getStateFlow(NavController.KEY_DEEP_LINK_INTENT, Intent())
        .map { intent: Intent -> sharedAnalyzeUseCase.invoke(intent) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.Lazily,
            initialValue = SharedContent.Proceeding
        )
}

Hiltを使用していれば、

savedStateHandle: SavedStateHandle,

は自動でInjectされます。
このsavedStateHandleから「NavController.KEY_DEEP_LINK_INTENT」を取得すると、共有情報を取得することができます。

SharedAnalyzeUseCase

共有情報を含むIntentから送信されてきたテキストを取得する部分の記述です。
これは、従来の方法(Composeを使っていないとき)と同じ記述となります。

SharedAnalyzeUseCase.kt
class SharedAnalyzeUseCase @Inject constructor(
    private val spotRepository: SpotRepository,
) {
    fun invoke(intent: Intent): SharedContent<String> {
        if (intent.action != Intent.ACTION_SEND) {
            return SharedContent.Error(IllegalArgumentException("action is not action_send. (${intent.action})"))
        }

        return if (intent.type?.startsWith("text/") == true) {
            val textContent = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
            SharedContent.Analyzed(textContent)
        } else {
            return SharedContent.Error(IllegalArgumentException("type is not text. (${intent.type})"))
        }
    }
}

完成

上述のソースを含む全ソースはこちらです。
もっと素敵なやり方をご存じでしたら、ご一報ください!!

0
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
0
0