diff --git a/src/frontend/app/build.gradle.kts b/src/frontend/app/build.gradle.kts
index bd89936..5c35cf6 100644
--- a/src/frontend/app/build.gradle.kts
+++ b/src/frontend/app/build.gradle.kts
@@ -61,6 +61,9 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -96,6 +99,7 @@ dependencies {
implementation(libs.androidx.room.guava)
testImplementation(libs.androidx.room.testing)
implementation(libs.androidx.room.paging)
+ implementation(libs.androidx.work)
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
diff --git a/src/frontend/app/src/main/AndroidManifest.xml b/src/frontend/app/src/main/AndroidManifest.xml
index 031b69a..8b5f8c0 100644
--- a/src/frontend/app/src/main/AndroidManifest.xml
+++ b/src/frontend/app/src/main/AndroidManifest.xml
@@ -71,5 +71,10 @@
+
+
\ No newline at end of file
diff --git a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/ApplicationModule.kt b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/ApplicationModule.kt
index 5e49575..f20553f 100644
--- a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/ApplicationModule.kt
+++ b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/ApplicationModule.kt
@@ -12,16 +12,17 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
import okhttp3.OkHttpClient
import pt.ulisboa.ist.pharmacist.repository.local.PharmacistDatabase
import pt.ulisboa.ist.pharmacist.repository.remote.medicines.MedicineApi
import pt.ulisboa.ist.pharmacist.repository.remote.pharmacies.PharmacyApi
import pt.ulisboa.ist.pharmacist.repository.remote.upload.UploaderApi
import pt.ulisboa.ist.pharmacist.repository.remote.users.UsersApi
+import pt.ulisboa.ist.pharmacist.service.PharmacyNotificationService
import pt.ulisboa.ist.pharmacist.service.real_time_updates.RealTimeUpdatesService
import pt.ulisboa.ist.pharmacist.session.SessionManager
import pt.ulisboa.ist.pharmacist.session.SessionManagerSharedPrefs
-import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -140,4 +141,16 @@ object ApplicationModule {
Places.initialize(context, context.getString(R.string.google_maps_key))
return Places.createClient(context)
}
+
+ @Provides
+ @Singleton
+ fun providePharmacyNotificationService(
+ @ApplicationContext context: Context,
+ database: PharmacistDatabase
+ ): PharmacyNotificationService {
+ return PharmacyNotificationService(
+ applicationContext = context,
+ database = database
+ )
+ }
}
\ No newline at end of file
diff --git a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/PharmacistApplication.kt b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/PharmacistApplication.kt
index 53025ad..1851017 100644
--- a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/PharmacistApplication.kt
+++ b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/PharmacistApplication.kt
@@ -7,6 +7,10 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
@@ -15,14 +19,18 @@ import coil.request.CachePolicy
import coil.util.DebugLogger
import com.google.gson.Gson
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
+import pt.ulisboa.ist.pharmacist.service.PharmacyNotificationService
+import pt.ulisboa.ist.pharmacist.service.PharmacyNotificationWork
import pt.ulisboa.ist.pharmacist.service.real_time_updates.MedicineNotificationsBackgroundService
import pt.ulisboa.ist.pharmacist.service.real_time_updates.RealTimeUpdatesService
import pt.ulisboa.ist.pharmacist.session.SessionManager
-import javax.inject.Inject
+
/**
* The Pharmacist application.
@@ -31,7 +39,8 @@ import javax.inject.Inject
* @property sessionManager the manager used to handle the user session
*/
@HiltAndroidApp
-class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderFactory {
+class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderFactory,
+ Configuration.Provider {
@Inject
override lateinit var httpClient: OkHttpClient
@@ -47,6 +56,12 @@ class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderF
private val serviceScope = CoroutineScope(Dispatchers.Default)
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ @Inject
+ lateinit var notificationService: PharmacyNotificationService
+
override fun onCreate() {
super.onCreate()
@@ -59,6 +74,22 @@ class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderF
serviceScope.launch {
realTimeUpdatesService.startService()
}
+
+ val workRequest = PeriodicWorkRequestBuilder(
+ PERIODIC_PHARMACY_NOTIFICATION_INTERVAL,
+ java.util.concurrent.TimeUnit.MINUTES
+ )
+ .build()
+
+ WorkManager.getInstance(this).enqueue(workRequest)
+
+
+ serviceScope.launch {
+ while (true) {
+ notificationService.verifyNotifications()
+ delay(PHARMACY_NOTIFICATIONS_DELAY)
+ }
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
@@ -78,7 +109,7 @@ class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderF
companion object {
const val MEDICINE_NOTIFICATION_CHANNEL = "MedicineNotifications"
- private const val API_ENDPOINT_TYPE = "domain"
+ private const val API_ENDPOINT_TYPE = "render"
val API_ENDPOINT = when (API_ENDPOINT_TYPE) {
"localhost" -> "http://10.0.2.2:8080"
"ngrok" -> "https://2b02-2001-818-e871-b700-c937-8172-33bf-a88.ngrok-free.app"
@@ -89,6 +120,10 @@ class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderF
}
}
const val TAG = "PharmacistApp"
+ private const val PERIODIC_PHARMACY_NOTIFICATION_INTERVAL = 15L
+ private const val PHARMACY_NOTIFICATIONS_DELAY = 5000L
+
+
}
override fun newImageLoader(): ImageLoader {
@@ -110,4 +145,10 @@ class PharmacistApplication : DependenciesContainer, Application(), ImageLoaderF
.logger(DebugLogger())
.build()
}
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+
}
diff --git a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationService.kt b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationService.kt
new file mode 100644
index 0000000..78856cc
--- /dev/null
+++ b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationService.kt
@@ -0,0 +1,153 @@
+package pt.ulisboa.ist.pharmacist.service
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import pt.ulisboa.ist.pharmacist.PharmacistApplication
+import pt.ulisboa.ist.pharmacist.R
+import pt.ulisboa.ist.pharmacist.repository.local.PharmacistDatabase
+import pt.ulisboa.ist.pharmacist.repository.local.pharmacies.PharmacyEntity
+import pt.ulisboa.ist.pharmacist.ui.screens.pharmacy.PharmacyActivity
+
+
+class PharmacyNotificationService(
+ private val applicationContext: Context,
+ private val database: PharmacistDatabase
+) {
+ private val previousPharmacies = mutableSetOf()
+ private val mutex = Mutex()
+
+ suspend fun verifyNotifications(): Boolean = mutex.withLock {
+ val newPharmacies = mutableSetOf()
+ val location = getLocation() ?: return false
+
+ database.pharmacyDao().getAllPharmacies()
+ .forEach { pharmacy ->
+ if (pharmacyNearMe(
+ location,
+ pharmacy
+ ) && pharmacy.pharmacyId !in previousPharmacies
+ ) {
+ showPharmacyNotification(pharmacy.pharmacyId, pharmacy.name)
+ newPharmacies.add(pharmacy.pharmacyId)
+ }
+ }
+
+ previousPharmacies.clear()
+ previousPharmacies.addAll(newPharmacies)
+
+ return true
+ }
+
+ private suspend fun getLocation(): Location? {
+ val fusedLocationProviderClient: FusedLocationProviderClient =
+ LocationServices.getFusedLocationProviderClient(applicationContext)
+
+ if (ActivityCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return null
+ }
+
+ return suspendCancellableCoroutine { continuation ->
+ fusedLocationProviderClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
+ .addOnSuccessListener { receivedLocation ->
+ receivedLocation?.let {
+ continuation.resume(it)
+ }
+ }
+ }
+ }
+
+ private fun pharmacyNearMe(location: Location, pharmacy: PharmacyEntity): Boolean {
+ val pharmacyLocation = Location("pharmacy")
+ pharmacyLocation.latitude = pharmacy.latitude
+ pharmacyLocation.longitude = pharmacy.longitude
+
+ return location.distanceTo(pharmacyLocation) <= MAX_DISTANCE_METERS
+ }
+
+ private fun showPharmacyNotification(pharmacyId: Long, pharmacyName: String) {
+ val notificationIntent = PharmacyActivity.getNavigationIntent(
+ applicationContext,
+ pharmacyId
+ )
+
+ //TODO: Check what happens when the user clicks on the back button in the MedicineActivity after clicking on the notification
+ notificationIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+
+ val pendingNotiIntent: PendingIntent = PendingIntent.getActivity(
+ applicationContext,
+ 0,
+ notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+
+ val message = applicationContext.getString(
+ R.string.pharmacy_notification_message,
+ pharmacyName,
+ )
+
+ // Show notification to the user
+ val notificationCompat = NotificationCompat.Builder(
+ applicationContext,
+ PharmacistApplication.MEDICINE_NOTIFICATION_CHANNEL
+ )
+ .setSmallIcon(R.drawable.pharmacy_logo)
+ .setContentTitle(
+ applicationContext.getString(
+ R.string.pharmacy_notification_message,
+ pharmacyName
+ )
+ )
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingNotiIntent)
+ .setAutoCancel(true)
+ .build()
+
+ with(NotificationManagerCompat.from(applicationContext)) {
+ if (!checkNotificationPermission()) {
+ return@with
+ }
+ notify(PHARMACY_NOTIFICATION_ID, notificationCompat)
+ }
+ }
+
+ private fun checkNotificationPermission(): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ActivityCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+
+
+ companion object {
+ private const val PHARMACY_NOTIFICATION_ID = 1
+ private const val MAX_DISTANCE_METERS = 100
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationWork.kt b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationWork.kt
new file mode 100644
index 0000000..5425b6a
--- /dev/null
+++ b/src/frontend/app/src/main/kotlin/pt/ulisboa/ist/pharmacist/service/PharmacyNotificationWork.kt
@@ -0,0 +1,23 @@
+package pt.ulisboa.ist.pharmacist.service
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+
+@HiltWorker
+class PharmacyNotificationWork @AssistedInject constructor(
+ private val service: PharmacyNotificationService,
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ val success = service.verifyNotifications()
+
+ return if (success) Result.success() else Result.failure()
+ }
+
+}
\ No newline at end of file
diff --git a/src/frontend/app/src/main/res/values-es/strings.xml b/src/frontend/app/src/main/res/values-es/strings.xml
index bd750ae..f63ef4b 100644
--- a/src/frontend/app/src/main/res/values-es/strings.xml
+++ b/src/frontend/app/src/main/res/values-es/strings.xml
@@ -119,4 +119,5 @@
Estrella
Farmacia Favorita
Cargando medicamento...
+ Farmacia %1$s cerca de ti
\ No newline at end of file
diff --git a/src/frontend/app/src/main/res/values-pt/strings.xml b/src/frontend/app/src/main/res/values-pt/strings.xml
index c1331d4..9b428e1 100644
--- a/src/frontend/app/src/main/res/values-pt/strings.xml
+++ b/src/frontend/app/src/main/res/values-pt/strings.xml
@@ -119,4 +119,5 @@
Estrela
Farmácia Favorita
Carregando medicamento...
+ Farmácia %1$s perto de ti
\ No newline at end of file
diff --git a/src/frontend/app/src/main/res/values/strings.xml b/src/frontend/app/src/main/res/values/strings.xml
index b485128..78f3a9d 100644
--- a/src/frontend/app/src/main/res/values/strings.xml
+++ b/src/frontend/app/src/main/res/values/strings.xml
@@ -126,4 +126,5 @@
Star
Favorite Pharmacy
Loading medicine...
+ Pharmacy %1$s near you
\ No newline at end of file
diff --git a/src/frontend/gradle/libs.versions.toml b/src/frontend/gradle/libs.versions.toml
index ed15f3a..c07bce4 100644
--- a/src/frontend/gradle/libs.versions.toml
+++ b/src/frontend/gradle/libs.versions.toml
@@ -3,6 +3,7 @@ accompanistPagerVersion = "0.34.0"
agp = "8.4.1"
gson = "2.11.0"
hiltAndroidVersion = "2.51.1"
+hiltWorkVersion = "1.2.0"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
@@ -22,11 +23,14 @@ coil = "2.6.0"
pagingVersion = "3.3.0"
pagingComposeVersion = "3.3.0"
room = "2.6.1"
+work = "2.9.0"
[libraries]
accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanistPagerVersion" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWorkVersion" }
+androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWorkVersion" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-guava = { module = "androidx.room:room-guava", version.ref = "room" }
@@ -53,6 +57,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
+androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsCompose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "mapsCompose" }