[Must Have Joyce의 안드로이드 앱프로그래밍] 11장 미세먼지 앱 V 1.0: 레트로핏을 이용한 네트워크 통신

알린·2024년 1월 30일
0

HTTP와 API

클라이언트와 서버

  • 클라이언트: 서버에 요청해 원하는 데이터 등을 응답받는 시스템 역할
  • 서버: 클라이언트에게서 받은 요청을 처리해 응답을 주는 역할

HTTP

  • 클라이언트와 서버의 요청, 응답 형식의 약속

URL

  • 인터넷에서의 자원 위치

    http://www.[name].com/android/index.html
    http 👉 사용해야할 프로토콜 (http, https(HTTP 프로토콜의 보안 버전) 등)
    www.[name].com 👉 도메인 이름 (서버 위치)
    android/index.html 👉 자원이 존재하는 경로 정보 (도착할 html 파일의 위치 지정)

HTTP 요청 메서드

  • 자원에 어떤 행동을 원하는지 나타냄

    GET: 대상 자원 요청 (사용자 정보 요청)
    POST: 클라이언트에서 서버로 어떤 정보 제출 (사용자 정보 추가)
    PUT: 대상 자원을 대체 (사용자 정보 수정)
    DELETE: 대상 자원 삭제 (사용자 정보 삭제)


레트로핏 라이브러리와 JSON

  • HTTP 클라이언트
  • 요청 바디값(Request Body)과 응답 바디값(Response Body)을 원하는 타입으로 안전하게 바꾸어줌
  • 다음 3가지 요소 구현 필수

    인터페이스: HTTP 메서드들을 정의
    레트로핏 클래스: 레트로핏 클라이언트 객체를 생성
    데이터 클래스: JSON 데이터를 담음

레트로핏 라이브러리

  1. 모듈 수준 build.gradle에 다음 코드 추가
plugins {
    id("org.jetbrains.kotlin.kapt")
}
dependencies {
    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
}
  1. 상단의 Sync Now 클릭

데이터 클래스 구현

  • JSON에서 객체는 속성-값 쌍의 집합
    👉 속성: 문자열
    👉 : 기본 자료형이나 객체, 배열

  • JSON 데이터의 키 값과 일치하는 필드명을 가진 데이터 클래스 필요

  1. 다음 링크 접속
    👉 iqair 공기질 API 공식 문서

  2. Get nearest city data (GPS coordinates) 찾기

    http://api.airvisual.com/v2/nearest_city: URL 주소
    lat={{LATITUDE}}&lon={{LONGITUDE}}&key={{YOUR_API_KEY}}: 파라미터

    lat: 위도
    lon: 경도
    key: API 키

  3. Postman에서 GET 메서드를 선택해 URL 주소만 입력 후,
    다음과 같이 키-값을 넣어주고 [Send] 클릭

  4. 아래와 같이 서버로부터 JSON 데이터를 받아오기 성공
    👉 받아온 데이터를 복사해 레트로핏을 위한 데이터 클래스 만들 때 사용

  5. 안드로이드 스튜디오에서 json to kotlin class 플러그인 설치

  6. 다음과 같이 패키지 생성 후 kotlin data class File from JSON 클릭

  7. 4에서 복사한 JSON 데이터 그대로 붙여넣기 ➡️ Class Name에 AirQualityResponse 작성 ➡️ Advanced 클릭

  8. [Other] ➡️ [Enable inner Class Model] 체크 ➡️ [OK] ➡️ [Generate]
    👉 inner 클래스를 활용해 여러 코틀린 파일이 생기는 것 방지하기 위함

레트로핏 클래스 구현

  • 레트로핏 빌더 사용해 레트로핏 객체 생성

💡 빌더 클래스

  • API의 베이스 URLJSON 객체를 변환해줄 GSON 컨버터를 사용
  1. retrofit package 내에 RetrofitConnection.kt 파일 생성 후 다음 코드 작성
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class RetrofitConnection {
    // 객체를 하나만 생성하는 싱글턴 패턴 적용
    companion object {
        // API 서버의 주소가 BASE_URL이 됨
        private const val BASE_URL = "https://api.airvisual.com/v2/"
        private var INSTANCE: Retrofit? = null

        fun getInstance(): Retrofit {
            if (INSTANCE == null) {  // null인 경우에만 생성
                INSTANCE = Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            }
            return INSTANCE!!
        }
    }
}

인터페이스 구현

  • HTTP 메서드를 작성해 사용할 API들을 정의
  1. retrofit package 내부에 AirQualityService 인터페이스 생성

  2. AirQualityService에 다음 코드 작성

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface AirQualityService {
    @GET("nearest_city")
    fun getAirQualityData(
        @Query("lat") lat: String,
        @Query("lon") lon: String,
        @Query("key") key: String
    ) : Call<AirQualityResponse>
}

GPS와 인터넷 권한 설정하기

  1. AndroidManifest.xml 파일에서 manifest태그 내에 권한 추가
    <!--인터넷 권한 필수 작성-->
    <uses-permission android:name="android.permission.INTERNET" />

    <!--위치 액세 권한 필수 작성-->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <!--앱이 정확한 위치 액세스를 통해 이점을 얻을 시 작성-->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

ACCESS_FINE_LOCATION: 사용자의 정확한 위치 권한 반환
ACCESS_COARSE_LOCATION: 사용자의 대략적인 위치 반환 권한

  1. 런타임에서 권한과 위치 서비스 확인

    💡 위치 서비스
    👉 GPS나 네트워크를 프로바이더로 설정할 수 있음
    GPS: 위성 신호를 수신하여 위치를 판독
    네트워크: WiFi 네트워크, 기지국 등으로부터 위치를 수신

package com.example.airquality_app

import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.airquality_app.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    // 런타임 권한 요청 시 필요한 요청 코드
    private val PERMISSIONS_REQUEST_CODE = 100
    //요청할 권한 목록
    var REQUIRED_PERMISSIONS = arrayOf(
        android.Manifest.permission.ACCESS_FINE_LOCATION,
        android.Manifest.permission.ACCESS_COARSE_LOCATION
    )

    // 위치 서비스 요청 시 필요한 런처
    lateinit var getGPSPermissionLauncher: ActivityResultLauncher<Intent>
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        checkAllPermissions()  // 권한 확인
    }

    private fun checkAllPermissions() {
        // 1. 위치 서비스가 켜져 있는지 확인
        if (!isLocationServicesAvailable()) {
            showDialogForLocationServiceSetting()
        } else {  // 2. 런타임 앱 권한이 모두 허용되어 있는지 확인
            isRuntimePermissionsGranted()
        }
    }

    // 위치 서비스 켜져 있는지 확인
    fun isLocationServicesAvailable(): Boolean {
        val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager

        return (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
    }

    // 위치 퍼미션을 가지고 있는지 확인
    fun isRuntimePermissionsGranted() {
        val hasFineLocationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_FINE_LOCATION
        )
        val hasCoarseLicationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_COARSE_LOCATION
        )
        if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED || hasCoarseLicationPermission != PackageManager.PERMISSION_GRANTED) {
            // 권한이 한 개라도 없다면 퍼미션 요청
            ActivityCompat.requestPermissions(this@MainActivity, REQUIRED_PERMISSIONS,PERMISSIONS_REQUEST_CODE)
        }
    }

    // 권한 요청 후 결괏값을 처리 => 모든 퍼미션이 허용되었는지 확인 (허용되지 않은 권한이 있다면 앱 종료)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == PERMISSIONS_REQUEST_CODE && grantResults.size == REQUIRED_PERMISSIONS.size) {
            // 요청 코드가 PERMISSIONS_REQUEST_CODE 이고, 요청한 퍼미션 개수만큼 수신되었다면
            var checkResult = true

            // 모든 퍼미션이 허용되었는지 확인
            for (result in grantResults) {
                if(result != PackageManager.PERMISSION_GRANTED) {
                    checkResult = false
                    break
                }
            }
            if (checkResult) {
                // 위칫값 가져오기
            } else {
                // 퍼미션 거부되면 앱 종료
                Toast.makeText(this@MainActivity, "권한이 거부되었습니다. 앱을 다시 실행하여 권한을 허용해주세요.", Toast.LENGTH_LONG).show()
                finish()
            }
        }
    }

    // 위치 서비스가 꺼져있다면 다이얼로그를 사용해 위치 서비스 설정
    private fun showDialogForLocationServiceSetting() {
        // getGPSPermissionLauncher => 결괏값을 반환해야 하는 인텐트 실행해줌
        getGPSPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            // 결괏값을 받았을 때
            result -> if (result.resultCode == Activity.RESULT_OK) {
                // 사용자가 GPS를 활성화시켰는지 확인
                if (isLocationServicesAvailable()) {
                    // 런타임 권한 확인
                    isRuntimePermissionsGranted()
                } else {
                    // 위치 서비스가 허용되지 않았다면 앱 종료
                    Toast.makeText(this@MainActivity, "위치 서비스를 사용할 수 없습니다.", Toast.LENGTH_LONG).show()
                    finish()
                }
            }
        }

        // 사용자에게 의사를 물어보는 AlertDialog 생성
        val builder: AlertDialog.Builder = AlertDialog.Builder(this@MainActivity)
        builder.setTitle("위치 서비스 비활성화")  // 제목
        builder.setMessage("위치 서비스가 꺼져 있습니다. 설정해야 앱을 사용할 수 있습니다.")  // 내용
        builder.setCancelable(true)  // 다이얼로그 창 바깥 터치 시 창 닫힘
        builder.setPositiveButton("설정", DialogInterface.OnClickListener {  // 확인 버튼 설정
            dialog, id ->  val callGPSSettingIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
            getGPSPermissionLauncher.launch(callGPSSettingIntent)
        })
        builder.setNegativeButton("취소", DialogInterface.OnClickListener{  // 취소 버튼 설정
            dialog, id ->  dialog.cancel()
            Toast.makeText(this@MainActivity, "기기에서 위치서비스(GPS) 설정 후 사용해주세요.", Toast.LENGTH_SHORT).show()
            finish()
        })
        // 다이얼로그 생성 및 출력
        builder.create().show()
    }
}

👉 위치 정보 엑세스 권한 요청 Android 공식 문서

권한 설정 기능 구현 화면

권한 허용 시

권한 허용하지 않았을 시


위치 정보 가져오기

  • com.example.airquality_app 아래에 LocationProvider.kt 클래스 생성 후 다음 코드 작성
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import androidx.core.content.ContextCompat

class (val context: Context) {
   private var location: Location? = null
    private var locationManager: LocationManager? = null

    init {
        getLocation()
    }

    private fun getLocation(): Location? {
        try {
            // 위치 시스템 서비스 가져오기
            locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
            var gpsLocation: Location? = null
            var networkLocation: Location? = null

            // GPS Provider와 Network Provider가 활성화 되어 있는지 확인
            val isGPSEnabled: Boolean = locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER)
            val isNetworkEnabled: Boolean = locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER)

            if (!isGPSEnabled && !isNetworkEnabled) {
                // GPS, Network Provider 둘 다 사용 불가능한 상황이면 null 반환
                return null
            } else {
                // ACCESS_COARSE_LOCATION 보다 더 정밀한 위치 정보 얻기
                val hasFineLocationPermission = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION)
                // 도시 block 단위 정밀도의 위치 정보 얻기
                val hasCoarseLocationPermission = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION)

                // 위의 두 개 권한 없다면 null 반환
                if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED || hasCoarseLocationPermission != PackageManager.PERMISSION_GRANTED)
                    return null

                // GPS를 통한 위치 파악이 가능한 경우에 위치를 가져옴
                if (isGPSEnabled) {
                    gpsLocation = locationManager?.getLastKnownLocation(LocationManager.GPS_PROVIDER)
                }
                // 네트워크를 통한 위치 파악이 가능한 경우에 위치를 가져옴
                if (isNetworkEnabled) {
                    networkLocation = locationManager?.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
                }

                if (gpsLocation != null && networkLocation != null) {
                    // 두 개 위치가 있다면 정확도 높은 것으로 선택
                    if (gpsLocation.accuracy > networkLocation.accuracy) {
                        location = gpsLocation
                        return gpsLocation
                    } else {
                        location = networkLocation
                        return networkLocation
                    }
                } else {
                    // 가능한 위치 정보가 한 개만 있는 경우
                    if (gpsLocation != null) {
                        location = gpsLocation
                    }

                    if (networkLocation != null) {
                        location = networkLocation
                    }
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()  // 에러 출력
        }
        return location
    }

    // 위도 정보 가져오는 함수
    fun getLocationLatitude(): Double {
        return location?.latitude ?: 0.0  // null이면 0.0 반환
    }

    // 경도 정보 가져오는 함수
    fun getLocationLongitude(): Double {
        return location?.longitude ?: 0.0  // null이면 0.0 반환
    }
}

💡 Location 클래스, Location Manager 클래스

  • Location 클래스: 위도, 경도, 고도와 같이 위치에 관련된 정보를 가지고 있는 클래스
  • Location Manager 클래스: 시스템 위치 서비스에 접근을 제공하는 클래스

위치 정보 메인 액티비티에 나타내기

  • MainActivity.kt을 다음과 같이 수정
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Address
import android.location.Geocoder
import android.location.LocationManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.airquality_app.databinding.ActivityMainBinding
import java.io.IOException
import java.lang.IllegalArgumentException
import java.util.Locale

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    // 런타임 권한 요청 시 필요한 요청 코드
    private val PERMISSIONS_REQUEST_CODE = 100
    //요청할 권한 목록
    var REQUIRED_PERMISSIONS = arrayOf(
        android.Manifest.permission.ACCESS_FINE_LOCATION,
        android.Manifest.permission.ACCESS_COARSE_LOCATION
    )

    // 위치 서비스 요청 시 필요한 런처
    lateinit var getGPSPermissionLauncher: ActivityResultLauncher<Intent>

    // 위도와 경도 가져올 떄 필요
    lateinit var locationProvider: LocationProvider

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        checkAllPermissions()  // 권한 확인
        updateUI()
        
        // 새로고침 버튼 클릭 시
        binding.btnRefresh.setOnClickListener {
            updateUI()
        }
    }

    private fun updateUI() {
        locationProvider = LocationProvider(this@MainActivity)

        // 위도와 경도 정보를 가져옴
        val latitude: Double = locationProvider.getLocationLatitude()
        val longitude: Double = locationProvider.getLocationLongitude()

        if (latitude != 0.0 || longitude != 0.0) {
            // 현재 위치를 가져오기
            val address = getCurrentAddress(latitude, longitude)
            // 주소가 null이 아닐 경우 UI 업데이트
            address?.let { 
                binding.tvLocationTitle.text = "${it.thoroughfare}"
                binding.tvLocationSubtitle.text = "${it.countryName} ${it.adminArea}"
            }
            
            // 현재 미세먼지 농도 가져오고 UI 업데이트
        } else {
            Toast.makeText(this@MainActivity, "위도, 경도 정보를 가져올 수 없습니다. 새로고침을 눌러주세요.", Toast.LENGTH_LONG).show()
        }
    }

    private fun checkAllPermissions() {
        // 1. 위치 서비스가 켜져 있는지 확인
        if (!isLocationServicesAvailable()) {
            showDialogForLocationServiceSetting()
        } else {  // 2. 런타임 앱 권한이 모두 허용되어 있는지 확인
            isRuntimePermissionsGranted()
        }
    }

    // 위치 서비스 켜져 있는지 확인
    fun isLocationServicesAvailable(): Boolean {
        val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager

        return (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
    }

    // 위치 퍼미션을 가지고 있는지 확인
    fun isRuntimePermissionsGranted() {
        val hasFineLocationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_FINE_LOCATION
        )
        val hasCoarseLicationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_COARSE_LOCATION
        )
        if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED || hasCoarseLicationPermission != PackageManager.PERMISSION_GRANTED) {
            // 권한이 한 개라도 없다면 퍼미션 요청
            ActivityCompat.requestPermissions(this@MainActivity, REQUIRED_PERMISSIONS,PERMISSIONS_REQUEST_CODE)
        }
    }

    // 권한 요청 후 결괏값을 처리 => 모든 퍼미션이 허용되었는지 확인 (허용되지 않은 권한이 있다면 앱 종료)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == PERMISSIONS_REQUEST_CODE && grantResults.size == REQUIRED_PERMISSIONS.size) {
            // 요청 코드가 PERMISSIONS_REQUEST_CODE 이고, 요청한 퍼미션 개수만큼 수신되었다면
            var checkResult = true

            // 모든 퍼미션이 허용되었는지 확인
            for (result in grantResults) {
                if(result != PackageManager.PERMISSION_GRANTED) {
                    checkResult = false
                    break
                }
            }
            if (checkResult) {
                // 위칫값 가져오기
                updateUI()          
            } else {
                // 퍼미션 거부되면 앱 종료
                Toast.makeText(this@MainActivity, "권한이 거부되었습니다. 앱을 다시 실행하여 권한을 허용해주세요.", Toast.LENGTH_LONG).show()
                finish()
            }
        }
    }

    // 위치 서비스가 꺼져있다면 다이얼로그를 사용해 위치 서비스 설정
    private fun showDialogForLocationServiceSetting() {
        // getGPSPermissionLauncher => 결괏값을 반환해야 하는 인텐트 실행해줌
        getGPSPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            // 결괏값을 받았을 때
            result -> if (result.resultCode == Activity.RESULT_OK) {
                // 사용자가 GPS를 활성화시켰는지 확인
                if (isLocationServicesAvailable()) {
                    // 런타임 권한 확인
                    isRuntimePermissionsGranted()
                } else {
                    // 위치 서비스가 허용되지 않았다면 앱 종료
                    Toast.makeText(this@MainActivity, "위치 서비스를 사용할 수 없습니다.", Toast.LENGTH_LONG).show()
                    finish()
                }
            }
        }

        // 사용자에게 의사를 물어보는 AlertDialog 생성
        val builder: AlertDialog.Builder = AlertDialog.Builder(this@MainActivity)
        builder.setTitle("위치 서비스 비활성화")  // 제목
        builder.setMessage("위치 서비스가 꺼져 있습니다. 설정해야 앱을 사용할 수 있습니다.")  // 내용
        builder.setCancelable(true)  // 다이얼로그 창 바깥 터치 시 창 닫힘
        builder.setPositiveButton("설정", DialogInterface.OnClickListener {  // 확인 버튼 설정
            dialog, id ->  val callGPSSettingIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
            getGPSPermissionLauncher.launch(callGPSSettingIntent)
        })
        builder.setNegativeButton("취소", DialogInterface.OnClickListener{  // 취소 버튼 설정
            dialog, id ->  dialog.cancel()
            Toast.makeText(this@MainActivity, "기기에서 위치서비스(GPS) 설정 후 사용해주세요.", Toast.LENGTH_SHORT).show()
            finish()
        })
        // 다이얼로그 생성 및 출력
        builder.create().show()
    }
    
    // 지오코딩 함수
    fun getCurrentAddress(latitude: Double, longitude: Double) : Address? {
        val geocoder = Geocoder(this, Locale.getDefault())
        // Address 객체는 주소와 관련된 여러 정보를 갖고 있음 => android.location.Address 참고
        val addresses: List<Address>?
        
        addresses = try {
            // Geocoder 객체를 이용하여 위도와 경도로부터 리스트를 가져옴
            geocoder.getFromLocation(latitude, longitude, 7)
        } catch (ioException: IOException) {
            Toast.makeText(this@MainActivity, "지오코더 서비스 사용 불가합니다.", Toast.LENGTH_SHORT).show()
            return null
        } catch (illegalArgumentException: IllegalArgumentException) {
            Toast.makeText(this@MainActivity, "잘못된 경도, 위도 입니다.", Toast.LENGTH_SHORT).show()
            return null
        }
        
        // 에러는 아니지만 주소가 발견되지 않은 경우
        if (addresses == null || addresses.size == 0) {
            Toast.makeText(this@MainActivity, "주소가 발견되지 않았습니다.", Toast.LENGTH_SHORT).show()
            return null
        }
        
        val address: Address = addresses[0]
        return address
    }
}

💡 지오코딩

  • 주소나 지명위도와 경도로 변환하거나, 위도와 경도주소나 지명으로 변환하는 작업

🚨 위도, 경도는 정상이지만 화면에 null로 출력되는 오류

  • 다음 메소드들은 각 주소를 반환해주는 메소드들임

    getAdminArea - 시 , 도
    getLocality , getSubLocality - 구
    getThoroughfare - 동
    getSubThoroughfare - 번지
    getFeatureName - 세부주소
    getPostalCode - 우편번호

  • 원래 지역마다 모두 테스트해본 후 경우에 따라 적절한 함수를 구현해야하지만,
    현재는 공부하는 단계이므로 다음 함수에 로그 코드 구현하여 내 위치에서 null값이 나오지 않는 메소드로 넣어서 출력함
    private fun updateUI() {
        locationProvider = LocationProvider(this@MainActivity)
        // 위도와 경도 정보를 가져옴
        val latitude: Double = locationProvider.getLocationLatitude()
        val longitude: Double = locationProvider.getLocationLongitude()
        Log.d("MyTag", "Latitude: $latitude, Longitude: $longitude")
        if (latitude != 0.0 || longitude != 0.0) {
            // 현재 위치를 가져오기
            val address = getCurrentAddress(latitude, longitude)
            // 주소가 null이 아닐 경우 UI 업데이트
            var adminArea  : String? = address?.adminArea
            var locality  : String? = address?.locality
            var thoroughfare  : String? = address?.thoroughfare
            var subThoroughfare  : String? = address?.subThoroughfare
            var featureName  : String? = address?.featureName
            var postalCode  : String? = address?.postalCode
            Log.d("MyTag", "adminArea: $adminArea, locality: $locality, thoroughfare: $thoroughfare\nsubThoroughfare: $subThoroughfare, featureName: $featureName, postalCode $postalCode")
            address?.let {
                if (it.thoroughfare == null) {
                    binding.tvLocationTitle.text = "${it.locality} 내위치"
                } else {
                    binding.tvLocationTitle.text = "${it.thoroughfare}"
                }
                binding.tvLocationSubtitle.text = "${it.countryName} ${it.adminArea}"
            }
            // 현재 미세먼지 농도 가져오고 UI 업데이트
            getAirQualityData(latitude, longitude)
        } else {
            Toast.makeText(this@MainActivity, "위도, 경도 정보를 가져올 수 없습니다. 새로고침을 눌러주세요.", Toast.LENGTH_LONG).show()
        }
    }

레트로핏 이용해 데이터 가져오기

  • MainActivity.kt에 다음 코드 추가
package com.example.airquality_app

import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Address
import android.location.Geocoder
import android.location.LocationManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.airquality_app.databinding.ActivityMainBinding
import com.example.airquality_app.retrofit.AirQualityResponse
import com.example.airquality_app.retrofit.AirQualityService
import com.example.airquality_app.retrofit.RetrofitConnection
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import java.lang.IllegalArgumentException
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    // 런타임 권한 요청 시 필요한 요청 코드
    private val PERMISSIONS_REQUEST_CODE = 100
    //요청할 권한 목록
    var REQUIRED_PERMISSIONS = arrayOf(
        android.Manifest.permission.ACCESS_FINE_LOCATION,
        android.Manifest.permission.ACCESS_COARSE_LOCATION
    )

    // 위치 서비스 요청 시 필요한 런처
    lateinit var getGPSPermissionLauncher: ActivityResultLauncher<Intent>

    // 위도와 경도 가져올 떄 필요
    lateinit var locationProvider: LocationProvider

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        checkAllPermissions()  // 권한 확인
        updateUI()

        // 새로고침 버튼 클릭 시
        setRefreshButton()
    }

    private fun setRefreshButton() {
        binding.btnRefresh.setOnClickListener {
            updateUI()
        }
    }

    private fun updateUI() {
        locationProvider = LocationProvider(this@MainActivity)

        // 위도와 경도 정보를 가져옴
        val latitude: Double = locationProvider.getLocationLatitude()
        val longitude: Double = locationProvider.getLocationLongitude()

        Log.d("MyTag", "Latitude: $latitude, Longitude: $longitude")

        if (latitude != 0.0 || longitude != 0.0) {
            // 현재 위치를 가져오기
            val address = getCurrentAddress(latitude, longitude)

            // 주소가 null이 아닐 경우 UI 업데이트
            var adminArea  : String? = address?.adminArea
            var locality  : String? = address?.locality
            var thoroughfare  : String? = address?.thoroughfare
            var subThoroughfare  : String? = address?.subThoroughfare
            var featureName  : String? = address?.featureName
            var postalCode  : String? = address?.postalCode
            Log.d("MyTag", "adminArea: $adminArea, locality: $locality, thoroughfare: $thoroughfare\nsubThoroughfare: $subThoroughfare, featureName: $featureName, postalCode $postalCode")
            address?.let {
                if (it.thoroughfare == null) {
                    binding.tvLocationTitle.text = "${it.locality} 내위치"
                } else {
                    binding.tvLocationTitle.text = "${it.thoroughfare}"
                }
                binding.tvLocationSubtitle.text = "${it.countryName} ${it.adminArea}"
            }
            // 현재 미세먼지 농도 가져오고 UI 업데이트
            getAirQualityData(latitude, longitude)
        } else {
            Toast.makeText(this@MainActivity, "위도, 경도 정보를 가져올 수 없습니다. 새로고침을 눌러주세요.", Toast.LENGTH_LONG).show()
        }
    }

    private fun getAirQualityData(latitude: Double, longitude: Double) {
        // 레트로핏 객체를 이용해 AirQualityService 인터페이스 구현체를 가져올 수 있음
        val retrofitAPI = RetrofitConnection.getInstance().create(AirQualityService::class.java)

        // retrofitAPI를 이용해 Call 객체를 만든 후 enqueue() 함수를 실행하여 서버에 API 요청을 보냄
        retrofitAPI.getAirQualityData(
            latitude.toString(),
            longitude.toString(),
            "21537d86-a4d2-4690-81a3-d1feed52ed5b"  // API key
        ).enqueue(object : Callback<AirQualityResponse> {  // Callback<AirQualityResponse> : 함수의 반환값
            override fun onResponse(call: Call<AirQualityResponse>, response: Response<AirQualityResponse>) {
                // 정상적인 Response가 왔다면 UI 업데이트
                if (response.isSuccessful) {
                    Toast.makeText(this@MainActivity, "최신 정보 업데이트 완료!", Toast.LENGTH_SHORT).show()
                    // response.body()가 null이 아니면 updateAirUI()
                    response.body()?.let {
                        updateAirUI(it) }
                } else {
                    Toast.makeText(this@MainActivity, "업데이트에 실패했습니다.", Toast.LENGTH_SHORT).show()
                }
            }

            override fun onFailure(call: Call<AirQualityResponse>, t: Throwable) {
                t.printStackTrace()
                Log.e("MyTag", "API 요청 실패: ${t.message}")
                Toast.makeText(this@MainActivity, "서버로부터 응답을 받아오지 못했습니다.", Toast.LENGTH_SHORT).show()
            }
        })
    }

    // 가져온 데이터 정보를 바탕으로 UI update
    private fun updateAirUI(airQualityResponse: AirQualityResponse) {
        val pollutionData = airQualityResponse.data.current.pollution

        // 수치 UI 지정
        // aqius: 미국 기준 Air Quality Index 값(대기 지수)
        binding.tvCount.text = pollutionData.aqius.toString()

        // 측정된 날짜 UI 지정
        // ts: 현재 응답으로 오는 시간 데이터 => 2024-01-30T13:00:00.000Z 형식
        val dateTime = ZonedDateTime.parse(pollutionData.ts).withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDateTime()  // ZonedDateTime 클래스 => 서울 시간대 적용
        val dateFormatter : DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")  // DateTimeFormatter.ofPattern() 함수 => 2023-01-30 23:00 형식으로 변환

        binding.tvCheckTime.text = dateTime.format(dateFormatter).toString()

        // 지수값을 기준으로 범위 나누어 대기 농도 평가 텍스트와 배경 이미지 변경
        when (pollutionData.aqius) {
            in 0..50 -> {
                binding.tvTitle.text = "좋음"
                binding.imgBg.setImageResource(R.drawable.bg_good)
            }
            in 51..150 -> {
                binding.tvTitle.text = "보통"
                binding.imgBg.setImageResource(R.drawable.bg_soso)
            }
            in 151..200 -> {
                binding.tvTitle.text = "나쁨"
                binding.imgBg.setImageResource(R.drawable.bg_bad)
            }
            else -> {
                binding.tvTitle.text = "매우나쁨"
                binding.imgBg.setImageResource(R.drawable.bg_worst)
            }
        }
    }

    private fun checkAllPermissions() {
        // 1. 위치 서비스가 켜져 있는지 확인
        if (!isLocationServicesAvailable()) {
            showDialogForLocationServiceSetting()
        } else {  // 2. 런타임 앱 권한이 모두 허용되어 있는지 확인
            isRuntimePermissionsGranted()
        }
    }

    // 위치 서비스 켜져 있는지 확인
    fun isLocationServicesAvailable(): Boolean {
        val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager

        return (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
    }

    // 위치 퍼미션을 가지고 있는지 확인
    fun isRuntimePermissionsGranted() {
        val hasFineLocationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_FINE_LOCATION
        )
        val hasCoarseLicationPermission = ContextCompat.checkSelfPermission(
            this@MainActivity, android.Manifest.permission.ACCESS_COARSE_LOCATION
        )
        if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED || hasCoarseLicationPermission != PackageManager.PERMISSION_GRANTED) {
            // 권한이 한 개라도 없다면 퍼미션 요청
            ActivityCompat.requestPermissions(this@MainActivity, REQUIRED_PERMISSIONS,PERMISSIONS_REQUEST_CODE)
        }
    }

    // 권한 요청 후 결괏값을 처리 => 모든 퍼미션이 허용되었는지 확인 (허용되지 않은 권한이 있다면 앱 종료)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == PERMISSIONS_REQUEST_CODE && grantResults.size == REQUIRED_PERMISSIONS.size) {
            // 요청 코드가 PERMISSIONS_REQUEST_CODE 이고, 요청한 퍼미션 개수만큼 수신되었다면
            var checkResult = true

            // 모든 퍼미션이 허용되었는지 확인
            for (result in grantResults) {
                if(result != PackageManager.PERMISSION_GRANTED) {
                    checkResult = false
                    break
                }
            }
            if (checkResult) {
                // 위칫값 가져오기
                updateUI()
            } else {
                // 퍼미션 거부되면 앱 종료
                Toast.makeText(this@MainActivity, "권한이 거부되었습니다. 앱을 다시 실행하여 권한을 허용해주세요.", Toast.LENGTH_LONG).show()
                finish()
            }
        }
    }

    // 위치 서비스가 꺼져있다면 다이얼로그를 사용해 위치 서비스 설정
    private fun showDialogForLocationServiceSetting() {
        // getGPSPermissionLauncher => 결괏값을 반환해야 하는 인텐트 실행해줌
        getGPSPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            // 결괏값을 받았을 때
            result -> if (result.resultCode == Activity.RESULT_OK) {
                // 사용자가 GPS를 활성화시켰는지 확인
                if (isLocationServicesAvailable()) {
                    // 런타임 권한 확인
                    isRuntimePermissionsGranted()
                } else {
                    // 위치 서비스가 허용되지 않았다면 앱 종료
                    Toast.makeText(this@MainActivity, "위치 서비스를 사용할 수 없습니다.", Toast.LENGTH_LONG).show()
                    finish()
                }
            }
        }

        // 사용자에게 의사를 물어보는 AlertDialog 생성
        val builder: AlertDialog.Builder = AlertDialog.Builder(this@MainActivity)
        builder.setTitle("위치 서비스 비활성화")  // 제목
        builder.setMessage("위치 서비스가 꺼져 있습니다. 설정해야 앱을 사용할 수 있습니다.")  // 내용
        builder.setCancelable(true)  // 다이얼로그 창 바깥 터치 시 창 닫힘
        builder.setPositiveButton("설정", DialogInterface.OnClickListener {  // 확인 버튼 설정
            dialog, id ->  val callGPSSettingIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
            getGPSPermissionLauncher.launch(callGPSSettingIntent)
        })
        builder.setNegativeButton("취소", DialogInterface.OnClickListener{  // 취소 버튼 설정
            dialog, id ->  dialog.cancel()
            Toast.makeText(this@MainActivity, "기기에서 위치서비스(GPS) 설정 후 사용해주세요.", Toast.LENGTH_SHORT).show()
            finish()
        })
        // 다이얼로그 생성 및 출력
        builder.create().show()
    }

    // 지오코딩 함수
    fun getCurrentAddress(latitude: Double, longitude: Double) : Address? {
        val geocoder = Geocoder(this, Locale.getDefault())
        // Address 객체는 주소와 관련된 여러 정보를 갖고 있음 => android.location.Address 참고
        val addresses: List<Address>?

        addresses = try {
            // Geocoder 객체를 이용하여 위도와 경도로부터 리스트를 가져옴
            geocoder.getFromLocation(latitude, longitude, 7)
        } catch (ioException: IOException) {
            Toast.makeText(this@MainActivity, "지오코더 서비스 사용 불가합니다.", Toast.LENGTH_SHORT).show()
            return null
        } catch (illegalArgumentException: IllegalArgumentException) {
            Log.e("MyTag", "IllegalArgumentException: ${illegalArgumentException.message}")
            Toast.makeText(this@MainActivity, "잘못된 경도, 위도 입니다.", Toast.LENGTH_SHORT).show()
            return null
        }

        // 에러는 아니지만 주소가 발견되지 않은 경우
        if (addresses == null || addresses.size == 0) {
            Toast.makeText(this@MainActivity, "주소가 발견되지 않았습니다.", Toast.LENGTH_SHORT).show()
            return null
        }

        val address: Address = addresses[0]
        return address
    }
}

💡 Call 객체

  • 레트로핏에서 요청을 처리하는 객체
  • HTTP 요청을 보내는 두 가지 방식 제공
  1. execute(): 동기적으로 요청을 보내고 응답을 받음
    👉 함수가 실행되는 스레드에서 실행되므로, 만약 메인 스레드에서 해당 함수를 실행할 경우 응답이 오기까지 UI가 블로킹
  2. enqueue(retrofit2,.Callback): 비동기적으로 백그라운드 스레드에서 요청을 보내고, 응답이 오면 등록한 콜백 함수를 실행
    👉 메인 스레드에서 실행하더라도 백그라운드 스레드에서 요청이 처리되어 UI가 블로킹되지 않음

구현 결과

profile
Android 짱이 되고싶은 개발 기록 (+ ios도 조금씩,,👩🏻‍💻)

0개의 댓글