NASA APIを使って「Astronomy Picture of the Day」のデータを取得してAndroidアプリを作ってみた

2023.06.19

Jetpack Composeの勉強をしています。

それなりに作れるようにはなってきましたが業務に携わっているわけではないので推奨された作り方を知りません。

Jetpack ComposeによるAndroid MVVMアーキテクチャ入門」を参考に、NASA APIを使ってAndroidアプリを作ってみました。

発行日:2021/09/03 となっていましたので、Android デベロッパー の アプリ アーキテクチャ ガイド と照らし合わせてみても 知識がないため2023年でも同じかよくわかりません。

作成したアプリをいろいろといじくりながら知識を深めていきたいと思っています。

今回作成した、備忘録です。

準備

NASA API Key の取得

環境

Android Studio: Flamingo 2022.2.1 Patch2

作成

1. プロジェクトの準備

  • ・Retrofit/Gson: Web Apiの連携
  • ・Hilt: DI (Dependency Injection: 依存関係の注入) *ここが本当にできているか課題
  • ・Coil: 画像読み込み

build.gradle(:app)

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
}

android {
    namespace 'com.example.astronomypicture'
    compileSdk 33

    defaultConfig {
        applicationId "com.example.astronomypicture"
        minSdk 24
        targetSdk 33
        versionCode 1
        versionName "1.0"

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

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.3.2'
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.10.1'
    implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
    implementation 'androidx.activity:activity-compose:1.7.2'
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'

    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"

    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"
    implementation "com.google.code.gson:gson:2.8.9"

    implementation "io.coil-kt:coil-compose:2.0.0"

    implementation("com.google.dagger:hilt-android:2.44")
    kapt("com.google.dagger:hilt-android-compiler:2.44")

    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    debugImplementation 'androidx.compose.ui:ui-tooling'
    debugImplementation 'androidx.compose.ui:ui-test-manifest'
}

build.gradle(Project)

plugins {
    id 'com.android.application' version '7.4.1' apply false
    id 'com.android.library' version '7.4.1' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
    id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
    id("com.google.dagger.hilt.android") version "2.44" apply false
}

HiltによるDIを行うためのApplicationクラス作成

AstronomyApplication.kt

@HiltAndroidApp
class AstronomyApplication : Application()

Web API連携する際のインターネット接続のパーミッション、Applicationクラスをマニフェストに登録

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".AstronomyApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AstronomyPicture"
        tools:targetApi="31">
        <activity
            android:name=".view.MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.AstronomyPicture">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <meta-data
            android:name="API_KEY"
            android:value = "${API_KEY}" />

    </application>

</manifest>

2. Modelにあたる部分作成

ApodItem.kt

interface ApiService {
    @GET("planetary/apod?")
    suspend fun getApodData(
        @Query("start_date") startDate: String,
        @Query("end_date") endDate: String,
        @Query("api_key") apiKey: String
    ): List<ApodItem>
}

ApiRepos.kt

class ApiRepos @Inject constructor(){
    companion object{
        const val  BASE_URL = "https://api.nasa.gov/"
    }

    fun getRetrofit(): ApiService {
        return Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl(BASE_URL)
            .build()
            .create(ApiService::class.java)
    }
}

ApiModule.kt

@Module
@InstallIn(SingletonComponent::class)
class ApiModule {

    @Provides
    @Singleton
    fun provideApiService(apiRepos: ApiRepos): ApiService{
        return apiRepos.getRetrofit()
    }
}

DataSource.kt

interface DataSource {
    suspend fun getApodData(
       startDate: String,
        endDate: String,
         apiKey: String
    ):List<ApodItem>
}

DataSourceImpl.kt

class DataSourceImpl  @Inject constructor(
    private val apiService: ApiService
    ): DataSource{
    private var errorMessage: String by mutableStateOf("")
    override suspend fun getApodData(
        startDate: String, 
        endDate: String, 
        apiKey: String
    ): List<ApodItem> {
        
        val response = apiService.getApodData(
            startDate=startDate, 
            endDate=endDate, 
            apiKey=apiKey
        )
        Log.d("Response", "$response")
        try {
            apopUser.clear()
            apopUser.addAll(response)

        } catch (e: Exception) {
            errorMessage = e.message.toString()
        }
        return response
    }
}

DataSourceModule.kt

@Module
@InstallIn(ViewModelComponent::class)
class DataSourceModule {
    @Provides
    fun provideDataSource(dataSourceImpl: DataSourceImpl): DataSource{
        return dataSourceImpl
    }
}

Repository.kt

val apopUser = mutableStateListOf<ApodItem>()

3. ViewModel 作成

MainViewModel.kt

@HiltViewModel
class MainViewModel @Inject constructor (
    private val dataSource: DataSource
    ) : ViewModel() {

    sealed class UiState {

        object Initial : UiState()

        object Loading : UiState()

        data class Success(val user: MutableList<ApodItem>) : UiState()

        object Failure : UiState()
    }

    val uiState: MutableState<UiState> = mutableStateOf(UiState.Initial)

    val startSearchQuery: MutableState<String> = mutableStateOf("")
    val endSearchQuery: MutableState<String> = mutableStateOf("")

    fun onSearchTapped() {

        val startSearchQuery: String = startSearchQuery.value
        val endSearchQuery: String = endSearchQuery.value

        viewModelScope.launch {
            uiState.value = UiState.Loading

            runCatching {
                dataSource.getApodData(
                    startDate = startSearchQuery, 
                    endDate = endSearchQuery, 
                    apiKey = BuildConfig.API_KEY
                )

            }.onSuccess {
                uiState.value = UiState.Success(user = apopUser)

            }.onFailure {
                uiState.value = UiState.Failure
            }
        }
    }
}

4. Viewにあたる部分作成

MainActivity.kt

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {


        super.onCreate(savedInstanceState)
        setContent {
            MainView(viewModel)
        }
    }
}

MainView.kt

@Composable
fun MainView(viewModel: MainViewModel) {

    val uiState: MainViewModel.UiState by viewModel.uiState

    Column(Modifier
        .fillMaxSize()
        .background(color = Color.Black)
    ) {
        SearchView(
            startSearchQuery = viewModel.startSearchQuery,
            endSearchQuery = viewModel.endSearchQuery,
            onSearchButtonTapped = viewModel::onSearchTapped,
        )
        Spacer(Modifier.size(10.dp))

        when(uiState){
            is MainViewModel.UiState.Initial ->{InitialView()}
            is MainViewModel.UiState.Loading -> {LoadingView()}
            is MainViewModel.UiState.Success ->{DetailView(apopUser)}
            is MainViewModel.UiState.Failure -> {ErrorView()}
        }

    }
}

SearchView.kt

@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun SearchView(startSearchQuery: MutableState<String>, endSearchQuery: MutableState<String>, onSearchButtonTapped: () -> Unit){

    val keyboardController = LocalSoftwareKeyboardController.current

    Row(
        Modifier
            .fillMaxWidth()
            .padding(start = 10.dp, top = 8.dp, end = 10.dp, bottom = 8.dp),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically

    ) {
        OutlinedTextField(value = startSearchQuery.value ,
            onValueChange = { text -> startSearchQuery.value = text},
            label  = {Text(stringResource(id = R.string.first_day), color = Color.White)},
            modifier = Modifier
                .width(130.dp),
            textStyle = TextStyle.Default.copy(fontSize = 16.sp),
            shape = RoundedCornerShape(8.dp),
            colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White,
                focusedLabelColor = Color.White,
                focusedBorderColor = Color.White
            )
        )

       Spacer(modifier = Modifier.width(5.dp))

        OutlinedTextField(value = endSearchQuery.value ,
            onValueChange = { text -> endSearchQuery.value = text},
            label  = {Text(stringResource(id = R.string.day_to), color = Color.White)},
            modifier = Modifier
                .width(130.dp),
            textStyle = TextStyle.Default.copy(fontSize = 16.sp),
            shape = RoundedCornerShape(8.dp),
            colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White,
                focusedLabelColor = Color.White,
                focusedBorderColor = Color.White,
            )
            )

        Spacer(Modifier.size(10.dp))

        Button(onClick = {onSearchButtonTapped()
            keyboardController?.hide() },
            contentPadding = PaddingValues(8.dp),
            colors = ButtonDefaults.textButtonColors(
                Color.Blue,
                contentColor = Color.White,
                disabledContentColor = Color.LightGray),
        ) {
            Text(stringResource(id = R.string.search ))
        }
    }
}

DetailView.kt

画像だけだと思ってたら、YouTubeもたまに混ざっており画像と同じように表示して再生できないか検索等してみましたが断念、URL表示させてURLをタップしたらリンクする形としました。

@Composable
fun DetailView(data: SnapshotStateList<ApodItem>){
   LazyColumn(modifier = Modifier
       .fillMaxSize()
       .padding(8.dp)
   ){
     items(data){item -> DetailItem(item = item)  }
   }
}

fun createAnnotatedString(
    image: String, color: androidx.compose.ui.graphics.Color
): AnnotatedString {
    return buildAnnotatedString {
        pushStringAnnotation(
            tag = "URL",
            annotation = image
        )
        withStyle(
            style = SpanStyle(
                color = color,
                fontWeight = FontWeight.Bold
            )
        ) {
            append(image)
        }
        pop()
    }
}

@Composable
fun DetailItem(item: ApodItem) {
    Column(modifier = Modifier
        .fillMaxSize()
        .background(Color.Black),
        horizontalAlignment = Alignment.CenterHorizontally
        ) {
        Box(modifier = Modifier
            .fillMaxWidth(),
            contentAlignment = Alignment.Center
            ) {
            Column(
                Modifier
                    .fillMaxWidth()
                    .wrapContentHeight(),
                horizontalAlignment = Alignment.CenterHorizontally

                ) {
                Text(text = item.date,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(start = 10.dp, top = 0.dp, end = 0.dp, bottom = 0.dp),
                    fontSize = 20.sp,
                    color = Color.White
                )

                Text(text = item.title,
                    Modifier
                        .fillMaxWidth()
                        .padding(start = 10.dp, top = 0.dp, end = 0.dp, bottom = 8.dp),
                    fontSize = 24.sp,
                    color = Color.White
                )
                 val image = item.url

                val urlStr = "apod.nasa.gov/apod"

                when(Regex(urlStr).containsMatchIn(image)){
                    true ->{
                        Image(
                            painter = rememberAsyncImagePainter(image),
                            contentDescription = null,
                            contentScale = ContentScale.Fit,
                            modifier = Modifier.size(width = 400.dp, height = 275.dp),
                            alignment = Alignment.Center
                        )
                    }
                    false -> {
                        val annotatedString = createAnnotatedString(image, Color.Magenta)
                        val uriHandler = AndroidUriHandler(LocalContext.current)
                        Column {
                            Text(text = stringResource(id = R.string.youtube),
                                color = Color.White,
                            modifier = Modifier.padding(5.dp),
                                style = TextStyle(fontSize = 18.sp)
                            )

                            ClickableText(text = annotatedString,
                                onClick = { offset ->
                                    annotatedString.getStringAnnotations(
                                        tag = "URL",
                                        start = offset,
                                        end = offset
                                    )
                                    uriHandler.openUri(image)
                                },
                                style = TextStyle(fontSize = 20.sp),
                                modifier = Modifier.padding(8.dp)
                            )
                        }
                    }
                }
            }
        }

                Text(text = item.explanation,
                    Modifier
                        .fillMaxWidth()
                        .padding(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
                    fontSize = 20.sp,
                    color = Color.White
                    )
    }
        Spacer(modifier = Modifier.height(20.dp))
}

InitialView.kt

@Composable
fun InitialView() {
    Text(
        text = "yyyy-mm-ddで日付を入力して検索してください",
        modifier = Modifier
            .fillMaxWidth()
            .padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
        fontSize = 16.sp,
        color = Color.White
    )
}

LoadingView.kt

@Composable
fun LoadingView() {
    Text(
        text = "読み込み中・・・",
        modifier = Modifier
            .fillMaxWidth()
            .padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
        fontSize = 16.sp,
        color = Color.White
    )
}

ErrorView.kt

@Composable
fun ErrorView() {
    Text(
        text = "読み込み失敗",
        modifier = Modifier
            .fillMaxWidth()
            .padding(start = 20.dp, top = 0.dp, end = 0.dp, bottom = 15.dp),
        fontSize = 16.sp,
        color = Color.White
    )
}

5. ひとまず出来上がり