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