diff --git a/README.md b/README.md index f4da86b4..7fdc7f53 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ +# 카카오 테크 캠퍼스 스텝2 클론 코딩 프로젝트, 지도 검색 앱 +- 제작 : 주민철 +- 기간 : 24.07~08 +- 사용 기술 : 코틀린, 안드로이드 스튜디오, 리사이클러뷰, Retrofit, LiveData, Mock, Hilt, MVVM, DataBinding, 코루틴, 파이어베이스, Room 데이터베이스. + +## 앱 설명 +![권한 설정](https://github.com/user-attachments/assets/e86063e0-6edb-4e99-84c3-05d8b5f6f28b) +앱을 처음 실행하면 스플래시 화면에서 알림 권한을 요청함. +![초기 화면](https://github.com/user-attachments/assets/e6fd2f98-211a-46a8-b5c3-dc9ce936269c) +초기 화면. 카카오 맵 API 사용. 검색창 클릭 시 검색 화면으로 이동. +![검색 화면](https://github.com/user-attachments/assets/d7c32e34-65f1-46ea-8c12-f3da512f268a) +검색 화면에서 검색어 입력 시 카카오 로컬 API를 사용해 검색 결과가 리사이클러뷰로 나옴. +![결과 클릭 시](https://github.com/user-attachments/assets/84de7d5d-311d-4bc7-a266-faac30a7d185) +검색 결과 클릭 시 지도에 장소를 표시함. BottomSheet로 간단한 정보도 표시. 이후 다시 검색창을 클릭 시 +![다시 검색 화면](https://github.com/user-attachments/assets/e5cfe1de-476c-4640-ab31-a7f13518ae09) +클릭 했던 검색 결과가 기록으로 남음. 검색 기록은 Room 데이터베이스에 저장. +![알림 화면](https://github.com/user-attachments/assets/3580b023-5711-4088-91d9-fd507d72669f) +파이어베이스를 통해 앱에 알림을 보낼 수도 있음. + # android-map-notification ## 1단계 - Splash Screen @@ -12,3 +31,13 @@ - 서버 상태, UI 로딩 등에 대한 상태 관리를 한다. - 새로 추가되는 부분에도 MVVM 아키텍처 패턴을 적용한다. - 코드 컨벤션을 준수하며 프로그래밍한다. + +## 2단계 - 푸시 알림 +### 기능 요구 사항 +- Firebase Cloud Message를 설정한다. +- 테스트 메시지를 보낸다. + - 앱이 백그라운드 상태일 경우 FCM 기본 값을 사용하여 Notification을 발생한다. + - 앱이 포그라운드 상태일 경우 커스텀 Notification을 발생한다. +- Notification 창을 터치하면 초기 진입 화면이 호출된다. +### 프로그래밍 요구 사항 +- 코드 컨벤션을 준수하며 프로그래밍한다. diff --git a/app/src/androidTest/java/campus/tech/kakao/map/MapActivityUITest.kt b/app/src/androidTest/java/campus/tech/kakao/map/MapActivityUITest.kt index f933846c..3a65ff6d 100644 --- a/app/src/androidTest/java/campus/tech/kakao/map/MapActivityUITest.kt +++ b/app/src/androidTest/java/campus/tech/kakao/map/MapActivityUITest.kt @@ -6,6 +6,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.rules.ActivityScenarioRule import campus.tech.kakao.map.view.MapActivity +import org.junit.Rule +import org.junit.Test class MapActivityUITest { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13425fbc..f7f4e6f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,15 @@ android:supportsRtl="true" android:theme="@style/Theme.Map" tools:targetApi="31"> + + + + + + - + android:exported="true" /> - \ No newline at end of file + diff --git a/app/src/main/java/campus/tech/kakao/map/Notification/MapFirebaseMessagingService.kt b/app/src/main/java/campus/tech/kakao/map/Notification/MapFirebaseMessagingService.kt new file mode 100644 index 00000000..3c6a34c4 --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/Notification/MapFirebaseMessagingService.kt @@ -0,0 +1,19 @@ +package campus.tech.kakao.map.Notification + + +import campus.tech.kakao.map.Notification.NotificationManager.createNotification +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +class MapFirebaseMessagingService: FirebaseMessagingService() { + override fun onMessageReceived(remoteMessage: RemoteMessage) { + remoteMessage.notification?.let {remoteMessageContent-> + createNotification(remoteMessageContent.title, remoteMessageContent.body, this) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + } + +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/Notification/NotificationManager.kt b/app/src/main/java/campus/tech/kakao/map/Notification/NotificationManager.kt new file mode 100644 index 00000000..bf1f31fe --- /dev/null +++ b/app/src/main/java/campus/tech/kakao/map/Notification/NotificationManager.kt @@ -0,0 +1,59 @@ +package campus.tech.kakao.map.Notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import campus.tech.kakao.map.R +import campus.tech.kakao.map.view.SplashScreen + +object NotificationManager { + private lateinit var notificationManager: NotificationManager + private const val NOTIFICATION_ID = 222222 + private const val CHANNEL_ID = "main_default_channel" + private const val CHANNEL_NAME = "main channelName" + private const val CHANNEL_DESCRIPTION = "main channelDescription" + private fun createNotificationChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = CHANNEL_DESCRIPTION + } + notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + + fun createNotification(title: String?, body: String?, context: Context){ + createNotificationChannel(context) + val intent = Intent(context, SplashScreen::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + val builder = NotificationCompat.Builder( + context, + CHANNEL_ID + ) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("[포그라운드] $title") + .setContentText("$body") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText("앱이 실행 중일 때는 포그라운드 알림이 발생합니다.") + ) + .setAutoCancel(true) + + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/campus/tech/kakao/map/view/SplashScreen.kt b/app/src/main/java/campus/tech/kakao/map/view/SplashScreen.kt index 75d53099..16998fd1 100644 --- a/app/src/main/java/campus/tech/kakao/map/view/SplashScreen.kt +++ b/app/src/main/java/campus/tech/kakao/map/view/SplashScreen.kt @@ -1,13 +1,23 @@ package campus.tech.kakao.map.view import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings +import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import campus.tech.kakao.map.R import campus.tech.kakao.map.databinding.ActivitySplashScreenBinding +import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.Firebase +import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.remoteConfig import com.google.firebase.remoteconfig.remoteConfigSettings @@ -16,6 +26,15 @@ import kotlinx.coroutines.launch class SplashScreen : AppCompatActivity() { var serviceMessage:String = "" + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + if (isGranted) { + getRemoteConfig() + } else { + getRemoteConfig() + } + } private lateinit var splashScreenBinding: ActivitySplashScreenBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -23,7 +42,7 @@ class SplashScreen : AppCompatActivity() { splashScreenBinding.splash = this installSplashScreen() setContentView(splashScreenBinding.root) - getRemoteConfig() + askNotificationPermission() } private fun moveMap(sec: Int) { @@ -51,4 +70,64 @@ class SplashScreen : AppCompatActivity() { } } } + + + + private fun askNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == + PackageManager.PERMISSION_GRANTED + ) { + getRemoteConfig() + } else if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) { + showNotificationPermissionDialog() + } else { + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + + } + } + } + + private fun showNotificationPermissionDialog() { + AlertDialog.Builder(this@SplashScreen).apply { + setTitle(getString(R.string.ask_notification_permission_dialog_title)) + setMessage( + String.format( + "다양한 알림 소식을 받기 위해 권한을 허용하시겠어요?\n(설정에서 %s의 알림 권한을 허용해주세요.)", + getString(R.string.app_name) + ) + ) + setPositiveButton(getString(R.string.yes)) { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.data = uri + startActivity(intent) + getRemoteConfig() + } + setNegativeButton(getString(R.string.deny_notification_permission)) { _, _ -> + getRemoteConfig() + } + show() + } + } + + override fun onRestart() { + getRemoteConfig() + super.onRestart() + } + + private fun getFcmToken(){ + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { + if(!it.isSuccessful){ + return@OnCompleteListener + } + val token = it.result + Log.d("testt", token.toString()) + } + ) + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8428f08..d7a4ce20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,4 +12,8 @@ serviceState ON_SERVICE serviceMessage + notification 요청 + yes + no + fcm_channel_description \ No newline at end of file diff --git a/app/src/test/java/campus/tech/kakao/map/FunTest.kt b/app/src/test/java/campus/tech/kakao/map/FunTest.kt index 5d88e0f1..efdeeb1f 100644 --- a/app/src/test/java/campus/tech/kakao/map/FunTest.kt +++ b/app/src/test/java/campus/tech/kakao/map/FunTest.kt @@ -12,9 +12,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @@ -40,7 +42,6 @@ class FunTest{ assert(actualQueryResult.any { it.placeName == query }) } - @Test fun 검색어_저장_되는지_확인(){ val query = SearchWord( diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \352\262\260\352\263\274.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \352\262\260\352\263\274.jpg" new file mode 100644 index 00000000..803e83d1 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \352\262\260\352\263\274.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \355\231\224\353\251\264.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \355\231\224\353\251\264.jpg" new file mode 100644 index 00000000..3e5faf64 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\200\354\203\211 \355\231\224\353\251\264.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\260\352\263\274 \355\201\264\353\246\255 \354\213\234.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\260\352\263\274 \355\201\264\353\246\255 \354\213\234.jpg" new file mode 100644 index 00000000..c6e19c50 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\262\260\352\263\274 \355\201\264\353\246\255 \354\213\234.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\266\214\355\225\234 \354\204\244\354\240\225.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\266\214\355\225\234 \354\204\244\354\240\225.jpg" new file mode 100644 index 00000000..1ee87ca4 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\352\266\214\355\225\234 \354\204\244\354\240\225.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\353\213\244\354\213\234 \352\262\200\354\203\211 \355\231\224\353\251\264.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\353\213\244\354\213\234 \352\262\200\354\203\211 \355\231\224\353\251\264.jpg" new file mode 100644 index 00000000..1800bb80 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\353\213\244\354\213\234 \352\262\200\354\203\211 \355\231\224\353\251\264.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\213\244\355\226\211 \354\230\201\354\203\201.mp4" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\213\244\355\226\211 \354\230\201\354\203\201.mp4" new file mode 100644 index 00000000..c283532e Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\213\244\355\226\211 \354\230\201\354\203\201.mp4" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\225\214\353\246\274 \355\231\224\353\251\264.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\225\214\353\246\274 \355\231\224\353\251\264.jpg" new file mode 100644 index 00000000..5e72fb85 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\225\214\353\246\274 \355\231\224\353\251\264.jpg" differ diff --git "a/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\264\210\352\270\260 \355\231\224\353\251\264.jpg" "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\264\210\352\270\260 \355\231\224\353\251\264.jpg" new file mode 100644 index 00000000..2f1c7ff7 Binary files /dev/null and "b/\354\213\244\355\226\211 \355\231\224\353\251\264/\354\264\210\352\270\260 \355\231\224\353\251\264.jpg" differ