From 1981e94327eccfe19041222d6cdd6273b92d397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1niel=20Zolnai?= Date: Thu, 8 Aug 2024 15:24:59 +0200 Subject: [PATCH] TIQR-451: Store and open notification when opening app from dashboard --- .../main/java/org/tiqr/core/MainActivity.kt | 3 + .../enrollment/EnrollmentSummaryFragment.kt | 39 +++++++++++++ .../core/messaging/TiqrMessagingService.kt | 15 +++-- data/src/main/AndroidManifest.xml | 1 + .../org/tiqr/data/module/RepositoryModule.kt | 13 +++++ .../data/repository/IdentityRepository.kt | 3 +- .../repository/NotificationCacheRepository.kt | 58 +++++++++++++++++++ .../tiqr/data/service/PreferenceService.kt | 55 ++++++++++++++++++ .../org/tiqr/data/viewmodel/MainViewModel.kt | 14 +++++ 9 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 data/src/main/java/org/tiqr/data/repository/NotificationCacheRepository.kt diff --git a/core/src/main/java/org/tiqr/core/MainActivity.kt b/core/src/main/java/org/tiqr/core/MainActivity.kt index 0f4850c9..1b5c7052 100644 --- a/core/src/main/java/org/tiqr/core/MainActivity.kt +++ b/core/src/main/java/org/tiqr/core/MainActivity.kt @@ -137,8 +137,11 @@ open class MainActivity : BaseActivity(), mainViewModel.parseChallenge(rawChallenge) // clear the intent since we have handled it intent.data = null + mainViewModel.clearCachedNotificationChallenge() + return } } + mainViewModel.tryCachedNotificationChallenge(this) } override fun onDestroy() { diff --git a/core/src/main/java/org/tiqr/core/enrollment/EnrollmentSummaryFragment.kt b/core/src/main/java/org/tiqr/core/enrollment/EnrollmentSummaryFragment.kt index a7077d4d..79a910fb 100644 --- a/core/src/main/java/org/tiqr/core/enrollment/EnrollmentSummaryFragment.kt +++ b/core/src/main/java/org/tiqr/core/enrollment/EnrollmentSummaryFragment.kt @@ -29,9 +29,16 @@ package org.tiqr.core.enrollment +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.LayoutRes +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint @@ -39,6 +46,7 @@ import org.tiqr.core.R import org.tiqr.core.base.BaseFragment import org.tiqr.core.databinding.FragmentEnrollmentSummaryBinding import org.tiqr.data.viewmodel.EnrollmentViewModel +import timber.log.Timber /** * Fragment to summarize the enrollment @@ -47,9 +55,25 @@ import org.tiqr.data.viewmodel.EnrollmentViewModel class EnrollmentSummaryFragment : BaseFragment() { private val viewModel by hiltNavGraphViewModels(R.id.enrollment_nav) + private lateinit var requestPermissionLauncher: ActivityResultLauncher + @LayoutRes override val layout = R.layout.fragment_enrollment_summary + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Initialize the ActivityResultLauncher + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + Timber.i("Notification permission granted") + } else { + // Permission is denied + Timber.w("Notification permission denied.") + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -57,5 +81,20 @@ class EnrollmentSummaryFragment : BaseFragment binding.buttonOk.setOnClickListener { findNavController().popBackStack() } + // If on Android 13+, we need to request permission to show push messages + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestNotificationPermission() + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun requestNotificationPermission() { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // Request the permission using the ActivityResultLauncher + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + // Permission already granted + Timber.i("Notification permission already granted.") + } } } \ No newline at end of file diff --git a/core/src/main/java/org/tiqr/core/messaging/TiqrMessagingService.kt b/core/src/main/java/org/tiqr/core/messaging/TiqrMessagingService.kt index 006aba2c..59b38108 100644 --- a/core/src/main/java/org/tiqr/core/messaging/TiqrMessagingService.kt +++ b/core/src/main/java/org/tiqr/core/messaging/TiqrMessagingService.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.tiqr.core.R +import org.tiqr.data.repository.NotificationCacheRepository import org.tiqr.data.repository.base.TokenRegistrarRepository import javax.inject.Inject @@ -64,19 +65,21 @@ class TiqrMessagingService : FirebaseMessagingService() { private val scope = CoroutineScope(Dispatchers.IO + job) @Inject - internal lateinit var repository: TokenRegistrarRepository + internal lateinit var tokenRegistrarRepository: TokenRegistrarRepository + + @Inject + internal lateinit var notificationCacheRepository: NotificationCacheRepository override fun onNewToken(token: String) { super.onNewToken(token) scope.launch { - repository.registerDeviceToken(token) + tokenRegistrarRepository.registerDeviceToken(token) } } override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) - sendNotification(message) } @@ -109,7 +112,6 @@ class TiqrMessagingService : FirebaseMessagingService() { Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS } val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - NotificationCompat.Builder(this, CHANNEL_ID) .setContentIntent(PendingIntent.getActivity(this, 0, intent, flags)) .setSmallIcon(R.drawable.ic_notification) @@ -124,7 +126,10 @@ class TiqrMessagingService : FirebaseMessagingService() { .setColor(resources.getColor(R.color.primaryColor)) .build() .apply { - notificationManager.notify(0, this) + val identifier = System.currentTimeMillis().toInt() + val authenticationTimeout = message.data["authenticationTimeout"]?.toIntOrNull() ?: 150 + notificationManager.notify(identifier, this) + notificationCacheRepository.saveLastNotificationData(challenge, authenticationTimeout, identifier) } } } diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index 9733c388..c5b76517 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt b/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt index 9bca1a04..9215f93a 100644 --- a/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt +++ b/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt @@ -43,6 +43,7 @@ import org.tiqr.data.di.DefaultDispatcher import org.tiqr.data.repository.AuthenticationRepository import org.tiqr.data.repository.EnrollmentRepository import org.tiqr.data.repository.IdentityRepository +import org.tiqr.data.repository.NotificationCacheRepository import org.tiqr.data.repository.TokenRepository import org.tiqr.data.repository.base.TokenRegistrarRepository import org.tiqr.data.service.DatabaseService @@ -103,3 +104,15 @@ class TokenRepositoryModule { @DefaultDispatcher dispatcher: CoroutineDispatcher ): TokenRegistrarRepository = TokenRepository(api, preferences, dispatcher) } + + +@Module +@InstallIn(SingletonComponent::class) +@VisibleForTesting +class NotificationCacheRepositoryModule { + @Provides + @Singleton + internal fun provideNotificationCacheRepository( + preferences: PreferenceService, + ): NotificationCacheRepository = NotificationCacheRepository(preferences) +} \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt b/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt index 974ddbe7..c43a2582 100644 --- a/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt +++ b/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt @@ -43,7 +43,8 @@ import org.tiqr.data.service.SecretService * Repository to interact with [Identity]. */ class IdentityRepository( - private val database: DatabaseService, private val secret: SecretService, + private val database: DatabaseService, + private val secret: SecretService, @DefaultDispatcher private val dispatcher: CoroutineDispatcher, ) { /** diff --git a/data/src/main/java/org/tiqr/data/repository/NotificationCacheRepository.kt b/data/src/main/java/org/tiqr/data/repository/NotificationCacheRepository.kt new file mode 100644 index 00000000..e9b555d4 --- /dev/null +++ b/data/src/main/java/org/tiqr/data/repository/NotificationCacheRepository.kt @@ -0,0 +1,58 @@ +package org.tiqr.data.repository + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import org.tiqr.data.service.PreferenceService +import java.util.Date + +class NotificationCacheRepository( + private val preferenceService: PreferenceService +) { + + /** + * Save the last notification data, to be used by the app if the user does not open the notification but the app instead. + * + * @param challenge: The challenge URL sent in the notification payload + * @param timeoutSeconds: The timeout in seconds. If this elapses, the challenge has timed out and all the data supplied in this method cannot be used anymore. + * @param notificationIdentifier: The identifier of the Android notification. This will be used to cancel the notification when we use this data. + */ + fun saveLastNotificationData(challenge: String, timeoutSeconds: Int, notificationIdentifier: Int) { + val timeoutEpoch = Date().time + timeoutSeconds * 1_000 + preferenceService.lastNotificationId = notificationIdentifier + preferenceService.lastNotificationChallenge = challenge + preferenceService.lastNotificationTimeoutEpochMs = timeoutEpoch + } + + /** + * Returns the last notification challenge, if it was valid. + * After returning the challenge, it will clear all last notification data, and cancel the notification. + */ + fun getLastNotificationChallenge(context: Context): String? { + val timeoutEpoch = preferenceService.lastNotificationTimeoutEpochMs ?: return null + var result: String? = null + if (Date().time <= timeoutEpoch) { + // Not timed out yet + result = preferenceService.lastNotificationChallenge + // Remove the local notification + val notificationId = preferenceService.lastNotificationId + if (notificationId != null) { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.cancel(notificationId) + } + } + preferenceService.lastNotificationId = null + preferenceService.lastNotificationChallenge = null + preferenceService.lastNotificationTimeoutEpochMs = null + return result + } + + /** + * Removes all data related to the last notification challenge. + */ + fun clearLastNotificationChallenge() { + preferenceService.lastNotificationId = null + preferenceService.lastNotificationChallenge = null + preferenceService.lastNotificationTimeoutEpochMs = null + } + +} \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/service/PreferenceService.kt b/data/src/main/java/org/tiqr/data/service/PreferenceService.kt index 36bb9a2f..ed9c7c73 100644 --- a/data/src/main/java/org/tiqr/data/service/PreferenceService.kt +++ b/data/src/main/java/org/tiqr/data/service/PreferenceService.kt @@ -32,10 +32,12 @@ package org.tiqr.data.service import android.content.Context import android.os.Build import android.util.Log +import androidx.core.app.NotificationManagerCompat import androidx.core.content.edit import timber.log.Timber import java.io.File import java.lang.RuntimeException +import java.util.Date /** * Service to save and retrieve data saved in shared preferences. @@ -48,6 +50,9 @@ class PreferenceService(private val context: Context) { private const val PREFS_KEY_VERSION = "version" private const val PREFS_KEY_TOKEN = "notification_token" + private const val PREFS_KEY_LAST_NOTIFICATION_ID = "last_notification_id" + private const val PREFS_KEY_LAST_NOTIFICATION_TIMEOUT_EPOCH = "last_notification_timeout_epoch" + private const val PREFS_KEY_LAST_NOTIFICATION_CHALLENGE = "last_notification_challenge" private const val PREFS_KEY_SALT = "salt" private const val PREFS_KEY_DEVICE_KEY = "device_key" private const val PREFS_KEY_NOTIFICATION_TOKEN_MIGRATION_EXECUTED = "notification_token_migration_executed" @@ -63,6 +68,56 @@ class PreferenceService(private val context: Context) { get() = notificationSharedPreferences.getString(PREFS_KEY_TOKEN, null) set(value) = notificationSharedPreferences.edit { putString(PREFS_KEY_TOKEN, value) } + var lastNotificationId: Int? + get() { + val result = notificationSharedPreferences.getInt(PREFS_KEY_LAST_NOTIFICATION_ID, Int.MIN_VALUE) + if (result == Int.MIN_VALUE) { + return null + } + return result + } + set(value) { + notificationSharedPreferences.edit { + if (value != null) { + putInt(PREFS_KEY_LAST_NOTIFICATION_ID, value) + } else { + remove(PREFS_KEY_LAST_NOTIFICATION_ID) + } + } + } + + var lastNotificationTimeoutEpochMs: Long? + get() { + val result = notificationSharedPreferences.getLong(PREFS_KEY_LAST_NOTIFICATION_TIMEOUT_EPOCH, Long.MIN_VALUE) + if (result == Long.MIN_VALUE) { + return null + } + return result + } + set(value) { + notificationSharedPreferences.edit { + if (value != null) { + putLong(PREFS_KEY_LAST_NOTIFICATION_TIMEOUT_EPOCH, value) + } else { + remove(PREFS_KEY_LAST_NOTIFICATION_TIMEOUT_EPOCH) + } + } + } + + var lastNotificationChallenge: String? + get() { + return notificationSharedPreferences.getString(PREFS_KEY_LAST_NOTIFICATION_CHALLENGE, null) + } + set(value) { + notificationSharedPreferences.edit { + if (value != null) { + putString(PREFS_KEY_LAST_NOTIFICATION_CHALLENGE, value) + } else { + remove(PREFS_KEY_LAST_NOTIFICATION_CHALLENGE) + } + } + } + var salt: String? get() = securitySharedPreferences.getString(PREFS_KEY_SALT, null) set(value) = securitySharedPreferences.edit { putString(PREFS_KEY_SALT, value) } diff --git a/data/src/main/java/org/tiqr/data/viewmodel/MainViewModel.kt b/data/src/main/java/org/tiqr/data/viewmodel/MainViewModel.kt index cbbd9230..d42f9a5e 100644 --- a/data/src/main/java/org/tiqr/data/viewmodel/MainViewModel.kt +++ b/data/src/main/java/org/tiqr/data/viewmodel/MainViewModel.kt @@ -29,12 +29,14 @@ package org.tiqr.data.viewmodel +import android.content.Context import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.tiqr.data.repository.AuthenticationRepository import org.tiqr.data.repository.EnrollmentRepository +import org.tiqr.data.repository.NotificationCacheRepository import org.tiqr.data.repository.TokenRepository import org.tiqr.data.repository.base.TokenRegistrarRepository import javax.inject.Inject @@ -45,6 +47,7 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( private val tokenRepository: TokenRegistrarRepository, + private val notificationCacheRepository: NotificationCacheRepository, private val enroll: EnrollmentRepository, private val auth: AuthenticationRepository ) : ViewModel() { @@ -77,4 +80,15 @@ class MainViewModel @Inject constructor( tokenRepository.executeTokenMigrationIfNeeded(getDeviceTokenFunction) } } + + fun tryCachedNotificationChallenge(context: Context) { + notificationCacheRepository.getLastNotificationChallenge(context)?.let { notificationChallenge -> + parseChallenge(notificationChallenge) + } + } + + fun clearCachedNotificationChallenge() { + notificationCacheRepository.clearLastNotificationChallenge() + } + } \ No newline at end of file