
http://www.[name].com/android/index.html
http 👉 사용해야할 프로토콜 (http, https(HTTP 프로토콜의 보안 버전) 등)
www.[name].com 👉 도메인 이름 (서버 위치)
android/index.html 👉 자원이 존재하는 경로 정보 (도착할 html 파일의 위치 지정)
GET: 대상 자원 요청 (사용자 정보 요청)
POST: 클라이언트에서 서버로 어떤 정보 제출 (사용자 정보 추가)
PUT: 대상 자원을 대체 (사용자 정보 수정)
DELETE: 대상 자원 삭제 (사용자 정보 삭제)
인터페이스: HTTP 메서드들을 정의
레트로핏 클래스: 레트로핏 클라이언트 객체를 생성
데이터 클래스: JSON 데이터를 담음
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")
}
JSON에서 객체는 속성-값 쌍의 집합
👉 속성: 문자열
👉 값: 기본 자료형이나 객체, 배열
JSON 데이터의 키 값과 일치하는 필드명을 가진 데이터 클래스 필요
다음 링크 접속
👉 iqair 공기질 API 공식 문서
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 키
Postman에서 GET 메서드를 선택해 URL 주소만 입력 후,
다음과 같이 키-값을 넣어주고 [Send] 클릭

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

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

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

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

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

💡 빌더 클래스
- API의 베이스 URL과 JSON 객체를 변환해줄 GSON 컨버터를 사용
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!!
}
}
}
retrofit package 내부에 AirQualityService 인터페이스 생성

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>
}
<!--인터넷 권한 필수 작성-->
<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: 사용자의 대략적인 위치 반환 권한

💡 위치 서비스
👉 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 공식 문서


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 클래스: 시스템 위치 서비스에 접근을 제공하는 클래스
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() } }
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 요청을 보내는 두 가지 방식 제공
- execute(): 동기적으로 요청을 보내고 응답을 받음
👉 함수가 실행되는 스레드에서 실행되므로, 만약 메인 스레드에서 해당 함수를 실행할 경우 응답이 오기까지 UI가 블로킹됨- enqueue(retrofit2,.Callback): 비동기적으로 백그라운드 스레드에서 요청을 보내고, 응답이 오면 등록한 콜백 함수를 실행
👉 메인 스레드에서 실행하더라도 백그라운드 스레드에서 요청이 처리되어 UI가 블로킹되지 않음
