diff --git a/app/.gitignore b/app/.gitignore index 65d12b95..98858169 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,3 @@ /build -google-services.json \ No newline at end of file +google-services.json +/src/main/assets/service-account.json \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b7aa0e6..7a66deff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ android { versionCode = 1 versionName = "1.0" buildConfigField("String", "FIREBASE_BASE_URL", getProperty("FIREBASE_BASE_URL")) + buildConfigField("String", "FIREBASE_SENDER_ID", getProperty("FIREBASE_SENDER_ID")) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -46,6 +47,9 @@ android { buildConfig = true dataBinding = true } + packaging { + resources.excludes.add("META-INF/*") + } } fun getProperty(key: String): String { @@ -62,6 +66,9 @@ dependencies { implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-auth") implementation("com.google.firebase:firebase-storage") + implementation("com.google.firebase:firebase-messaging") + implementation("com.google.firebase:firebase-messaging-directboot") + implementation("com.google.auth:google-auth-library-oauth2-http:1.23.0") implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") implementation("androidx.navigation:navigation-ui-ktx:2.7.6") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f48849b..4a3e847a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,5 +26,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/MyFirebaseMessagingService.kt b/app/src/main/java/com/sesac/developer_study_platform/MyFirebaseMessagingService.kt new file mode 100644 index 00000000..3db43455 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/MyFirebaseMessagingService.kt @@ -0,0 +1,38 @@ +package com.sesac.developer_study_platform + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.sesac.developer_study_platform.data.source.local.FcmTokenRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MyFirebaseMessagingService : FirebaseMessagingService() { + + private val fcmTokenRepository = FcmTokenRepository(this) + + override fun onNewToken(token: String) { + super.onNewToken(token) + + CoroutineScope(Dispatchers.IO).launch { + fcmTokenRepository.setToken(token) + } + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + // TODO(developer): Handle FCM messages here. + // Not getting messages here? See why this may be: https://goo.gl/39bRNJ + Log.d("fcm", "From: ${remoteMessage.from}") + + // Check if message contains a data payload. + if (remoteMessage.data.isNotEmpty()) { + Log.d("fcm", "Message data payload: ${remoteMessage.data}") + } + + // Check if message contains a notification payload. + remoteMessage.notification?.let { + Log.d("fcm", "Message Notification Body: ${it.body}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/StudyApplication.kt b/app/src/main/java/com/sesac/developer_study_platform/StudyApplication.kt index dd1423e1..e530db6c 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/StudyApplication.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/StudyApplication.kt @@ -7,6 +7,7 @@ import com.sesac.developer_study_platform.data.source.local.BookmarkDao import com.sesac.developer_study_platform.data.source.local.BookmarkRepository import com.sesac.developer_study_platform.data.source.local.MyStudyDao import com.sesac.developer_study_platform.data.source.local.MyStudyRepository +import com.sesac.developer_study_platform.data.source.remote.FcmRepository import com.sesac.developer_study_platform.data.source.remote.GithubRepository import com.sesac.developer_study_platform.data.source.remote.StudyRepository @@ -25,6 +26,7 @@ class StudyApplication : Application() { bookmarkRepository = BookmarkRepository() myStudyDao = db.myStudyDao() myStudyRepository = MyStudyRepository() + fcmRepository = FcmRepository(this) } override fun onTerminate() { @@ -39,5 +41,6 @@ class StudyApplication : Application() { lateinit var bookmarkRepository: BookmarkRepository lateinit var myStudyDao: MyStudyDao lateinit var myStudyRepository: MyStudyRepository + lateinit var fcmRepository: FcmRepository } } \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/FcmMessage.kt b/app/src/main/java/com/sesac/developer_study_platform/data/FcmMessage.kt new file mode 100644 index 00000000..896cb275 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/data/FcmMessage.kt @@ -0,0 +1,14 @@ +package com.sesac.developer_study_platform.data + +import kotlinx.serialization.Serializable + +@Serializable +data class FcmMessage( + val message: FcmMessageData, +) + +@Serializable +data class FcmMessageData( + val token: String = "", + val data: Map = mapOf(), +) \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/StudyGroup.kt b/app/src/main/java/com/sesac/developer_study_platform/data/StudyGroup.kt new file mode 100644 index 00000000..9612b8bd --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/data/StudyGroup.kt @@ -0,0 +1,12 @@ +package com.sesac.developer_study_platform.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyGroup( + val operation: String = "", + @SerialName("notification_key_name") val notificationKeyName: String = "", + @SerialName("registration_ids") val registrationIdList: List = listOf(), + @SerialName("notification_key") val notificationKey: String = "", +) \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/source/local/FcmTokenRepository.kt b/app/src/main/java/com/sesac/developer_study_platform/data/source/local/FcmTokenRepository.kt new file mode 100644 index 00000000..d500fd73 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/data/source/local/FcmTokenRepository.kt @@ -0,0 +1,34 @@ +package com.sesac.developer_study_platform.data.source.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private const val FCM_TOKEN_DATASTORE = "fcm_token_datastore" + +val Context.fcmTokenDataStore: DataStore by preferencesDataStore(FCM_TOKEN_DATASTORE) + +class FcmTokenRepository(private val context: Context) { + + suspend fun setToken(token: String) { + context.fcmTokenDataStore.edit { preferences -> + preferences[FCM_TOKEN_KEY] = token + } + } + + fun getToken(): Flow { + return context.fcmTokenDataStore.data + .map { preferences -> + preferences[FCM_TOKEN_KEY] ?: "" + } + } + + companion object { + private val FCM_TOKEN_KEY = stringPreferencesKey("fcm_token") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmRepository.kt b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmRepository.kt new file mode 100644 index 00000000..2f3df458 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmRepository.kt @@ -0,0 +1,18 @@ +package com.sesac.developer_study_platform.data.source.remote + +import android.content.Context +import com.sesac.developer_study_platform.data.FcmMessage +import com.sesac.developer_study_platform.data.StudyGroup + +class FcmRepository(context: Context) { + + private val fcmService = FcmService.create(context) + + suspend fun updateStudyGroup(studyGroup: StudyGroup): Map { + return fcmService.updateStudyGroup(studyGroup) + } + + suspend fun sendNotification(message: FcmMessage) { + fcmService.sendNotification(message) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmService.kt b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmService.kt new file mode 100644 index 00000000..86943033 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/FcmService.kt @@ -0,0 +1,63 @@ +package com.sesac.developer_study_platform.data.source.remote + +import android.content.Context +import com.google.auth.oauth2.GoogleCredentials +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.sesac.developer_study_platform.BuildConfig +import com.sesac.developer_study_platform.data.FcmMessage +import com.sesac.developer_study_platform.data.StudyGroup +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.POST + +interface FcmService { + + @POST("fcm/notification") + suspend fun updateStudyGroup( + @Body studyGroup: StudyGroup + ): Map + + @POST("v1/projects/developer-study-platform/messages:send") + suspend fun sendNotification( + @Body message: FcmMessage + ) + + companion object { + private const val BASE_URL = "https://fcm.googleapis.com" + private const val SCOPES = "https://www.googleapis.com/auth/firebase.messaging" + private val contentType = "application/json".toMediaType() + private val jsonConfig = Json { ignoreUnknownKeys = true } + + private fun getClient(context: Context): OkHttpClient { + return OkHttpClient.Builder().addInterceptor { chain -> + val builder = chain.request().newBuilder().apply { + addHeader("access_token_auth", "true") + addHeader("Authorization", "Bearer ${getAccessToken(context)}") + addHeader("project_id", BuildConfig.FIREBASE_SENDER_ID) + } + chain.proceed(builder.build()) + }.build() + } + + private fun getAccessToken(context: Context): String { + val inputStream = context.resources.assets.open("service-account.json") + val googleCredential = GoogleCredentials + .fromStream(inputStream) + .createScoped(listOf(SCOPES)) + googleCredential.refresh() + return googleCredential.accessToken.tokenValue + } + + fun create(context: Context): FcmService { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(getClient(context)) + .addConverterFactory(jsonConfig.asConverterFactory(contentType)) + .build() + .create(FcmService::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyRepository.kt b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyRepository.kt index 59eb89b1..55cd3e09 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyRepository.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyRepository.kt @@ -90,6 +90,18 @@ class StudyRepository { studyService.deleteUserStudy(uid, sid) } + suspend fun addNotificationKey(sid: String, notificationKey: String) { + studyService.addNotificationKey(sid, notificationKey) + } + + suspend fun getNotificationKey(sid: String): String? { + return studyService.getNotificationKey(sid) + } + + suspend fun addRegistrationId(sid: String, registrationId: String) { + studyService.addRegistrationId(sid, mapOf(registrationId to true)) + } + fun getMessageList(sid: String): Flow> = flow { while (true) { kotlin.runCatching { diff --git a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyService.kt b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyService.kt index 21d3f3f4..7ed3bcc8 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyService.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/data/source/remote/StudyService.kt @@ -150,6 +150,23 @@ interface StudyService { @Path("sid") sid: String ) + @PUT("studies/{sid}/notificationKey.json") + suspend fun addNotificationKey( + @Path("sid") sid: String, + @Body notificationKey: String + ) + + @GET("studies/{sid}/notificationKey.json") + suspend fun getNotificationKey( + @Path("sid") sid: String, + ): String? + + @PATCH("studies/{sid}/registrationIds.json") + suspend fun addRegistrationId( + @Path("sid") sid: String, + @Body registrationId: Map + ) + companion object { private const val BASE_URL = BuildConfig.FIREBASE_BASE_URL private val contentType = "application/json".toMediaType() diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogFragment.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogFragment.kt index 32aa284b..c2c1231e 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogFragment.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogFragment.kt @@ -1,10 +1,16 @@ package com.sesac.developer_study_platform.ui.detail +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController @@ -12,14 +18,26 @@ import androidx.navigation.fragment.navArgs import com.sesac.developer_study_platform.EventObserver import com.sesac.developer_study_platform.R import com.sesac.developer_study_platform.data.UserStudy +import com.sesac.developer_study_platform.data.source.local.FcmTokenRepository import com.sesac.developer_study_platform.databinding.DialogJoinStudyBinding class JoinStudyDialogFragment : DialogFragment() { private var _binding: DialogJoinStudyBinding? = null private val binding get() = _binding!! - private val viewModel by viewModels() + private val viewModel by viewModels { + JoinStudyDialogViewModel.create(FcmTokenRepository(requireContext())) + } private val args by navArgs() + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + updateStudyGroup() + } else { + Toast.makeText(context, getString(R.string.all_notification_info), Toast.LENGTH_SHORT).show() + viewModel.moveToMessage(args.study.sid) + } + } override fun onCreateView( inflater: LayoutInflater, @@ -62,7 +80,7 @@ class JoinStudyDialogFragment : DialogFragment() { viewModel.addUserStudyEvent.observe( viewLifecycleOwner, EventObserver { - viewModel.moveToMessage(args.study.sid) + askNotificationPermission() } ) } @@ -78,6 +96,39 @@ class JoinStudyDialogFragment : DialogFragment() { ) } + private fun askNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + updateStudyGroup() + } + + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + // TODO 권한 이유 다이얼로그 + } + + else -> { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } else { + updateStudyGroup() + } + } + + private fun updateStudyGroup() { + viewModel.updateStudyGroup(args.study.sid) + viewModel.updateStudyGroupEvent.observe( + viewLifecycleOwner, + EventObserver { + viewModel.moveToMessage(args.study.sid) + } + ) + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogViewModel.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogViewModel.kt index e1171ffd..a0463f6c 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogViewModel.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/detail/JoinStudyDialogViewModel.kt @@ -5,18 +5,28 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.google.firebase.Firebase import com.google.firebase.auth.auth import com.sesac.developer_study_platform.Event +import com.sesac.developer_study_platform.StudyApplication.Companion.fcmRepository import com.sesac.developer_study_platform.StudyApplication.Companion.studyRepository +import com.sesac.developer_study_platform.data.StudyGroup import com.sesac.developer_study_platform.data.UserStudy +import com.sesac.developer_study_platform.data.source.local.FcmTokenRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -class JoinStudyDialogViewModel : ViewModel() { +class JoinStudyDialogViewModel(private val fcmTokenRepository: FcmTokenRepository) : ViewModel() { private val _addUserStudyEvent: MutableLiveData> = MutableLiveData() val addUserStudyEvent: LiveData> = _addUserStudyEvent + private val _updateStudyGroupEvent: MutableLiveData> = MutableLiveData() + val updateStudyGroupEvent: LiveData> = _updateStudyGroupEvent + private val _moveToMessageEvent: MutableLiveData> = MutableLiveData() val moveToMessageEvent: LiveData> = _moveToMessageEvent @@ -50,7 +60,53 @@ class JoinStudyDialogViewModel : ViewModel() { } } + fun updateStudyGroup(sid: String) { + viewModelScope.launch { + val token = fcmTokenRepository.getToken().first() + kotlin.runCatching { + val notificationKey = getNotificationKey(sid) + if (!notificationKey.isNullOrEmpty()) { + fcmRepository.updateStudyGroup(StudyGroup("add", sid, listOf(token), notificationKey)) + } + }.onSuccess { + addRegistrationId(sid, token) + }.onFailure { + Log.e("JoinStudyDialogViewModel-updateStudyGroup", it.message ?: "error occurred.") + } + } + } + + private suspend fun getNotificationKey(sid: String): String? { + return viewModelScope.async { + kotlin.runCatching { + studyRepository.getNotificationKey(sid) + }.onFailure { + Log.e("JoinStudyDialogViewModel-getNotificationKey", it.message ?: "error occurred.") + }.getOrNull() + }.await() + } + + private fun addRegistrationId(sid: String, registrationId: String) { + viewModelScope.launch { + kotlin.runCatching { + studyRepository.addRegistrationId(sid, registrationId) + }.onSuccess { + _updateStudyGroupEvent.value = Event(Unit) + }.onFailure { + Log.e("JoinStudyDialogViewModel-addRegistrationId", it.message ?: "error occurred.") + } + } + } + fun moveToMessage(sid: String) { _moveToMessageEvent.value = Event(sid) } + + companion object { + fun create(fcmTokenRepository: FcmTokenRepository) = viewModelFactory { + initializer { + JoinStudyDialogViewModel(fcmTokenRepository) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageFragment.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageFragment.kt index a06eb4fa..f93557cb 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageFragment.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageFragment.kt @@ -74,7 +74,6 @@ class MessageFragment : Fragment() { binding.isNetworkConnected = isNetworkConnected(requireContext()) } - private fun setBackButton() { binding.toolbar.setNavigationOnClickListener { viewModel.moveToBack() diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageViewModel.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageViewModel.kt index 314c7da4..58cff01d 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageViewModel.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/message/MessageViewModel.kt @@ -10,7 +10,10 @@ import com.google.firebase.Firebase import com.google.firebase.auth.auth import com.google.firebase.storage.storage import com.sesac.developer_study_platform.Event +import com.sesac.developer_study_platform.StudyApplication.Companion.fcmRepository import com.sesac.developer_study_platform.StudyApplication.Companion.studyRepository +import com.sesac.developer_study_platform.data.FcmMessage +import com.sesac.developer_study_platform.data.FcmMessageData import com.sesac.developer_study_platform.data.Message import com.sesac.developer_study_platform.data.StudyUser import kotlinx.coroutines.SupervisorJob @@ -128,6 +131,7 @@ class MessageViewModel : ViewModel() { }.onSuccess { _addMessageEvent.value = Event(Unit) updateLastMessage(sid, message) + sendNotification(sid, message.message) }.onFailure { Log.e("MessageViewModel-sendImage", it.message ?: "error occurred.") } @@ -142,6 +146,7 @@ class MessageViewModel : ViewModel() { }.onSuccess { _addMessageEvent.value = Event(Unit) updateLastMessage(sid, message) + sendNotification(sid, message.message) }.onFailure { Log.e("MessageViewModel-sendMessage", it.message ?: "error occurred.") } @@ -256,6 +261,33 @@ class MessageViewModel : ViewModel() { } } + private fun sendNotification(sid: String, message: String) { + viewModelScope.launch { + kotlin.runCatching { + val notificationKey = getNotificationKey(sid) + if (!notificationKey.isNullOrEmpty()) { + fcmRepository.sendNotification( + FcmMessage( + FcmMessageData(notificationKey, mapOf("message" to message)) + ) + ) + } + }.onFailure { + Log.e("MessageViewModel-sendNotification", it.message ?: "error occurred.") + } + } + } + + private suspend fun getNotificationKey(sid: String): String? { + return viewModelScope.async { + kotlin.runCatching { + studyRepository.getNotificationKey(sid) + }.onFailure { + Log.e("MessageViewModel-getNotificationKey", it.message ?: "error occurred.") + }.getOrNull() + }.await() + } + fun moveToBack() { _moveToBackEvent.value = Event(Unit) } diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormFragment.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormFragment.kt index 166ebccd..5cd040f6 100644 --- a/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormFragment.kt +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormFragment.kt @@ -1,18 +1,24 @@ package com.sesac.developer_study_platform.ui.studyform +import android.Manifest +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import android.widget.Toast import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.AppCompatButton +import androidx.core.content.ContextCompat import androidx.core.util.Pair import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.datepicker.CalendarConstraints @@ -24,16 +30,18 @@ import com.google.android.material.timepicker.TimeFormat import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase import com.google.firebase.storage.ktx.storage +import com.sesac.developer_study_platform.EventObserver import com.sesac.developer_study_platform.R import com.sesac.developer_study_platform.data.ChatRoom import com.sesac.developer_study_platform.data.DayTime import com.sesac.developer_study_platform.data.Study import com.sesac.developer_study_platform.data.UserStudy +import com.sesac.developer_study_platform.data.source.local.FcmTokenRepository import com.sesac.developer_study_platform.data.source.remote.StudyService import com.sesac.developer_study_platform.databinding.FragmentStudyFormBinding -import com.sesac.developer_study_platform.util.isNetworkConnected import com.sesac.developer_study_platform.util.DateFormats import com.sesac.developer_study_platform.util.formatTimestamp +import com.sesac.developer_study_platform.util.isNetworkConnected import com.sesac.developer_study_platform.util.setImage import com.sesac.developer_study_platform.util.showSnackbar import kotlinx.coroutines.launch @@ -51,8 +59,12 @@ class StudyFormFragment : Fragment() { private var category = "" private var startDate = "" private var endDate = "" + private var sid = "" private lateinit var image: Uri private val studyService = StudyService.create() + private val viewModel by viewModels { + StudyFormViewModel.create(FcmTokenRepository(requireContext())) + } private val dayTimeAdapter = DayTimeAdapter(object : DayTimeClickListener { override fun onClick(isStartTime: Boolean, dayTime: DayTime) { showTimePicker(isStartTime, dayTime) @@ -62,6 +74,15 @@ class StudyFormFragment : Fragment() { registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> setSelectedImage(uri) } + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + createNotificationKey() + } else { + Toast.makeText(context, getString(R.string.all_notification_info), Toast.LENGTH_SHORT).show() + viewModel.moveToMessage(sid) + } + } override fun onCreateView( inflater: LayoutInflater, @@ -106,6 +127,7 @@ class StudyFormFragment : Fragment() { setTotalPeopleCount() setValidateAll() binding.isNetworkConnected = isNetworkConnected(requireContext()) + setNavigation() } private fun setImageButton() { @@ -336,7 +358,7 @@ class StudyFormFragment : Fragment() { else -> { val uid = Firebase.auth.uid uid?.let { - val sid = "@make@$uid@time@${formatTimestamp()}" + sid = "@make@$uid@time@${formatTimestamp()}" uploadImage(sid, image) { fileName -> saveStudy(sid, formatStudy(sid, uid, fileName)) saveUserStudy(uid, sid, formatUserStudy(sid, fileName)) @@ -389,8 +411,7 @@ class StudyFormFragment : Fragment() { kotlin.runCatching { studyService.addChatRoom(sid, ChatRoom()) }.onSuccess { - val action = StudyFormFragmentDirections.actionStudyFormToMessage(sid) - findNavController().navigate(action) + askNotificationPermission() }.onFailure { Log.e("StudyFormFragment-saveChatRoom", it.message ?: "error occurred.") } @@ -434,6 +455,49 @@ class StudyFormFragment : Fragment() { return list } + private fun setNavigation() { + viewModel.moveToMessageEvent.observe( + viewLifecycleOwner, + EventObserver { + val action = StudyFormFragmentDirections.actionStudyFormToMessage(it) + findNavController().navigate(action) + } + ) + } + + private fun askNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + createNotificationKey() + } + + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + // TODO 권한 이유 다이얼로그 + } + + else -> { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } else { + createNotificationKey() + } + } + + private fun createNotificationKey() { + viewModel.createNotificationKey(sid) + viewModel.createNotificationKeyEvent.observe( + viewLifecycleOwner, + EventObserver { + viewModel.moveToMessage(sid) + } + ) + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormViewModel.kt b/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormViewModel.kt new file mode 100644 index 00000000..a7659b40 --- /dev/null +++ b/app/src/main/java/com/sesac/developer_study_platform/ui/studyform/StudyFormViewModel.kt @@ -0,0 +1,74 @@ +package com.sesac.developer_study_platform.ui.studyform + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.sesac.developer_study_platform.Event +import com.sesac.developer_study_platform.StudyApplication.Companion.fcmRepository +import com.sesac.developer_study_platform.StudyApplication.Companion.studyRepository +import com.sesac.developer_study_platform.data.StudyGroup +import com.sesac.developer_study_platform.data.source.local.FcmTokenRepository +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class StudyFormViewModel(private val fcmTokenRepository: FcmTokenRepository) : ViewModel() { + + private val _createNotificationKeyEvent: MutableLiveData> = MutableLiveData() + val createNotificationKeyEvent: LiveData> = _createNotificationKeyEvent + + private val _moveToMessageEvent: MutableLiveData> = MutableLiveData() + val moveToMessageEvent: LiveData> = _moveToMessageEvent + + fun createNotificationKey(sid: String) { + viewModelScope.launch { + val token = fcmTokenRepository.getToken().first() + kotlin.runCatching { + fcmRepository.updateStudyGroup(StudyGroup("create", sid, listOf(token))) + }.onSuccess { + addNotificationKey(sid, it.values.first()) + }.onFailure { + Log.e("StudyFormViewModel-createNotificationKey", it.message ?: "error occurred.") + } + } + } + + private fun addNotificationKey(sid: String, notificationKey: String) { + viewModelScope.launch { + kotlin.runCatching { + studyRepository.addNotificationKey(sid, notificationKey) + }.onSuccess { + addRegistrationId(sid, fcmTokenRepository.getToken().first()) + }.onFailure { + Log.e("StudyFormViewModel-addNotificationKey", it.message ?: "error occurred.") + } + } + } + + private fun addRegistrationId(sid: String, registrationId: String) { + viewModelScope.launch { + kotlin.runCatching { + studyRepository.addRegistrationId(sid, registrationId) + }.onSuccess { + _createNotificationKeyEvent.value = Event(Unit) + }.onFailure { + Log.e("StudyFormViewModel-addRegistrationId", it.message ?: "error occurred.") + } + } + } + + fun moveToMessage(sid: String) { + _moveToMessageEvent.value = Event(sid) + } + + companion object { + fun create(fcmTokenRepository: FcmTokenRepository) = viewModelFactory { + initializer { + StudyFormViewModel(fcmTokenRepository) + } + } + } +} \ 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 1502887c..587cb1e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ com.sesac.developer_study_platform.DEVELOPER_STUDY_APP + fcm_default_channel + 검색 방만들기 @@ -31,6 +33,7 @@ 스터디 검색 사람 모양의 아이콘 + 이 앱은 알림을 표시하지 않습니다. 설정에서 알림 권한을 변경할 수 있습니다. AUTO_LOGIN Login with Github