Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TIQR-451: Store and open notification when opening app from dashboard #86

Merged
merged 1 commit into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/src/main/java/org/tiqr/core/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,11 @@ open class MainActivity : BaseActivity<ActivityMainBinding>(),
mainViewModel.parseChallenge(rawChallenge)
// clear the intent since we have handled it
intent.data = null
mainViewModel.clearCachedNotificationChallenge()
return
}
}
mainViewModel.tryCachedNotificationChallenge(this)
}

override fun onDestroy() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@

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
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
Expand All @@ -47,15 +55,46 @@ import org.tiqr.data.viewmodel.EnrollmentViewModel
class EnrollmentSummaryFragment : BaseFragment<FragmentEnrollmentSummaryBinding>() {
private val viewModel by hiltNavGraphViewModels<EnrollmentViewModel>(R.id.enrollment_nav)

private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>

@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)

binding.viewModel = viewModel
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.")
}
}
}
15 changes: 10 additions & 5 deletions core/src/main/java/org/tiqr/core/messaging/TiqrMessagingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions data/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
13 changes: 13 additions & 0 deletions data/src/main/java/org/tiqr/data/module/RepositoryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

}
55 changes: 55 additions & 0 deletions data/src/main/java/org/tiqr/data/service/PreferenceService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand All @@ -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) }
Expand Down
14 changes: 14 additions & 0 deletions data/src/main/java/org/tiqr/data/viewmodel/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
}

}
Loading