build.gradle
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
android {
namespace 'com.example.airquality'
compileSdk 34
defaultConfig {
applicationId "com.example.airquality"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
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'
}
viewBinding {
enabled = true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}
대시보드 접속
https://github.com/code-with-joyce/must_have_android/tree/image_source
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/tv_location_title"
android:text="역삼1동"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="-0.05"
android:textColor="#000000"
android:textSize="32sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="@+id/guideline1"
app:layout_constraintTop_toTopOf="@+id/guideline4" />
<TextView
android:id="@+id/tv_location_subtitle"
android:text="대한민국 서울특별시"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="-0.05"
android:textColor="#000000"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="@+id/guideline1"
app:layout_constraintTop_toBottomOf="@+id/tv_location_title" />
<ImageView
android:layout_width="10dp"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:src="@drawable/iocn_thunder"
app:layout_constraintBottom_toBottomOf="@id/tv_location_title"
app:layout_constraintStart_toEndOf="@id/tv_location_title"
app:layout_constraintTop_toTopOf="@id/tv_location_title" />
<ImageView
android:id="@+id/img_bg"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/bg_soso"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintEnd_toEndOf="@id/guideline3"
app:layout_constraintStart_toStartOf="@id/guideline2"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.4" />
<TextView
android:id="@+id/tv_count"
android:text="61"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.05"
android:textColor="#4c4c4c"
android:textSize="32sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/img_bg"
app:layout_constraintEnd_toEndOf="@id/img_bg"
app:layout_constraintStart_toStartOf="@id/img_bg"
app:layout_constraintTop_toTopOf="@id/img_bg"
/>
<TextView
android:id="@+id/tv_title"
android:text="보통"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="-0.05"
android:textColor="#999999"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="@id/img_bg"
app:layout_constraintStart_toStartOf="@id/img_bg"
app:layout_constraintTop_toBottomOf="@id/tv_count" />
<TextView
android:id="@+id/check_time"
android:text="측정 시간"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:letterSpacing="-0.05"
android:textColor="#999999"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/img_bg" />
<TextView
android:id="@+id/tv_check_time"
android:text="2021-08-29 13:00"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="-0.05"
android:textColor="#999999"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/check_time" />
<ImageView
android:id="@+id/btn_refresh"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginTop="20dp"
android:padding="5dp"
android:src="@drawable/icon_refresh"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_check_time" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.1" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.18" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.82" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.1" />
</androidx.constraintlayout.widget.ConstraintLayout>
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">
<!--Internet Permission-->
<uses-permission android:name="android.permission.INTERNET" />
<!--GPS & Location-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<application
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.AirQuality"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MainActivity.kt
package com.example.airquality
import android.Manifest
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 android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.airquality.databinding.ActivityMainBinding
import com.example.airquality.retrofit.AirQualityResponse
import com.example.airquality.retrofit.AirQualityService
import com.example.airquality.retrofit.RetrofitConnection
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
// 런타임 권한 요청시 필요한 요청 코드입니다.
private val PERMISSIONS_REQUEST_CODE = 100
// 요청할 권한 리스트 입니다.
var REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, 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()
if (latitude != 0.0 || longitude != 0.0) {
//1. 현재 위치를 가져오고 UI 업데이트
//현재 위치를 가져오기
val address = getCurrentAddress(latitude, longitude) //주소가 null 이 아닐 경우 UI 업데이트
address?.let {
binding.tvLocationTitle.text = "${it.thoroughfare}" // 예시: 역삼 1동
binding.tvLocationSubtitle.text = "${it.countryName} ${it.adminArea}" // 예시 : 대한민국 서울특별시
}
//2. 현재 미세먼지 농도 가져오고 UI 업데이트
getAirQualityData(latitude, longitude)
} else {
Toast.makeText(this@MainActivity, "위도, 경도 정보를 가져올 수 없었습니다. 새로고침을 눌러주세요.", Toast.LENGTH_LONG).show()
}
}
/**
* @desc 레트로핏 클래스를 이용하여 미세먼지 오염 정보를 가져옵니다.
* */
private fun getAirQualityData(latitude: Double, longitude: Double) { // 레트로핏 객체를 이용하면 AirQualityService 인터페이스 구현체를 가져올 수 있습니다.
val retrofitAPI = RetrofitConnection.getInstance().create(AirQualityService::class.java)
retrofitAPI.getAirQualityData(latitude.toString(), longitude.toString(), "f8f5a711-7da9-4118-a875-304ffded8cb8")
.enqueue(object : 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()
}
})
}
/**
* @desc 가져온 데이터 정보를 바탕으로 화면을 업데이트한다.
* */
private fun updateAirUI(airQualityData: AirQualityResponse) {
val pollutionData = airQualityData.data.current.pollution
//수치 지정 (가운데 숫자)
binding.tvCount.text = pollutionData.aqius.toString()
//측정된 날짜 지정
//"2021-09-04T14:00:00.000Z" 형식을 "2021-09-04 23:00"로 수정
val dateTime = ZonedDateTime.parse(pollutionData.ts).withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDateTime()
val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
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)
}
}
}
/**
* @desc 위도와 경도를 기준으로 실제 주소를 가져온다.
* */
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, "지오코더 서비스 사용불가합니다.", Toast.LENGTH_LONG).show()
return null
} catch (illegalArgumentException: IllegalArgumentException) {
Toast.makeText(this, "잘못된 위도, 경도 입니다.", Toast.LENGTH_LONG).show()
return null
}
//에러는 아니지만 주소가 발견되지 않은 경우
if (addresses == null || addresses.size == 0) {
Toast.makeText(this, "주소가 발견되지 않았습니다.", Toast.LENGTH_LONG).show()
return null
}
val address: Address = addresses[0]
return address
}
private fun checkAllPermissions() {
if (!isLocationServicesAvailable()) { //1. 위치 서비스(GPS)가 켜져있는지 확인합니다.
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, Manifest.permission.ACCESS_FINE_LOCATION)
val hasCoarseLocationPermission = ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_COARSE_LOCATION)
if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED || hasCoarseLocationPermission != PackageManager.PERMISSION_GRANTED) { // 권한이 한 개라도 없다면 퍼미션 요청을 합니다.
ActivityCompat.requestPermissions(this@MainActivity, REQUIRED_PERMISSIONS, PERMISSIONS_REQUEST_CODE)
}
}
/**
* @desc 런타임 권한을 요청하고 권한 요청에 따른 결과를 리턴한다.
* */
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()
}
}
}
/**
* @desc LocationManager를 사용하기 위해서 권한을 요청한다.
* */
private fun showDialogForLocationServiceSetting() {
//먼저 ActivityResultLauncher를 설정해줍니다. 이 런처를 이용하여 결과 값을 리턴해야하는 인텐트를 실행할 수 있습니다.
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()
}
}
}
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()
}
}
LocationProvider.kt
package com.example.airquality
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat.requestLocationUpdates
/**
* @author Joyce Hong
* @email joycehong0524@gmail.com
* @created 2021/08/29
* @desc
*/
class LocationProvider(val context: Context) {
//Location 클래스는 위도, 경도, 고도와 같이 위치에 관련된 정보를 가지고 있는 데이터 클래스입니다.
private var location: Location? = null
//Location Manager는 시스템 위치 서비스에 접근을 제공하는 클래스입니다.
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 {
val hasFineLocationPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION // ACCESS_COARSE_LOCATION 보다 더 정밀한 위치 정보를 얻을 수 있습니다.
)
val hasCoarseLocationPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION // 도시 Block 단위의 정밀도의 위치 정보를 얻을 수 있습니다.
)
//만약 위 두 개 권한 없다면 null을 반환합니다.
if (hasFineLocationPermission != PackageManager.PERMISSION_GRANTED ||
hasCoarseLocationPermission != PackageManager.PERMISSION_GRANTED
) return null
//네트워크를 통한 위치 파악이 가능한 경우에 위치를 가져옵니다.
if (isNetworkEnabled) {
networkLocation =
locationManager?.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
}
//GPS를 통한 위치 파악이 가능한 경우에 위치를 가져옵니다.
if (isGPSEnabled) {
gpsLocation =
locationManager?.getLastKnownLocation(LocationManager.GPS_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
}
//경도 정보르 가져오는 함수입니다.
fun getLocationLongitude(): Double {
return location?.longitude ?: 0.0
}
}