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" }