diff --git a/app/src/androidTest/java/com/futsch1/medtimer/AlarmIntentRedeliveryTest.kt b/app/src/androidTest/java/com/futsch1/medtimer/AlarmIntentRedeliveryTest.kt new file mode 100644 index 000000000..78e33f413 --- /dev/null +++ b/app/src/androidTest/java/com/futsch1/medtimer/AlarmIntentRedeliveryTest.kt @@ -0,0 +1,123 @@ +package com.futsch1.medtimer + +import android.app.Activity +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import com.futsch1.medtimer.core.domain.model.Medicine +import com.futsch1.medtimer.core.domain.model.Reminder +import com.futsch1.medtimer.core.domain.model.ReminderEvent +import com.futsch1.medtimer.feature.reminders.alarm.ReminderAlarmActivity +import com.futsch1.medtimer.feature.reminders.api.notificationData.ReminderNotificationData +import com.futsch1.medtimer.harness.RepositoryEntryPoint +import com.futsch1.medtimer.utilities.pollUntil +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant +import kotlin.test.assertTrue + +/** + * Regression test for https://github.com/Futsch1/medTimer/issues/1494 + * ("Alarm can display previous events instead of current ones"). + * + * The alarm activity is launched with [Intent.FLAG_ACTIVITY_SINGLE_TOP] + * (ReminderAlarmActivity.getIntent). When a second alarm intent arrives while the + * activity is already on top, Android delivers it via onNewIntent. Without an + * onNewIntent override the new data is dropped and the screen keeps showing the + * PREVIOUS alarm's events. + * + * This test drives the exact redelivery path deterministically: launch with the + * first alarm's data, deliver the second alarm's intent via onNewIntent, and assert + * the screen now shows the second alarm's medicine. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AlarmIntentRedeliveryTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun inject() { + hiltRule.inject() + } + + private val targetContext get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val entryPoint get() = EntryPointAccessors.fromApplication( + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext, + RepositoryEntryPoint::class.java + ) + + @Test + fun secondAlarmIntentReplacesDisplayedEvents() { + // Two medicines with one reminder and one raised event each, seeded directly. + val medAId = runBlocking { entryPoint.medicineRepository().create(Medicine.default().copy(name = "Meds A")) } + val reminderAId = runBlocking { entryPoint.reminderRepository().create(Reminder.default().copy(medicineRelId = medAId)) } + val eventA = runBlocking { + entryPoint.reminderEventRepository().create( + ReminderEvent.default().copy(reminderId = reminderAId, medicineName = "Meds A") + ) + } + val medBId = runBlocking { entryPoint.medicineRepository().create(Medicine.default().copy(name = "Meds B")) } + val reminderBId = runBlocking { entryPoint.reminderRepository().create(Reminder.default().copy(medicineRelId = medBId)) } + val eventB = runBlocking { + entryPoint.reminderEventRepository().create( + ReminderEvent.default().copy(reminderId = reminderBId, medicineName = "Meds B") + ) + } + + val dataA = ReminderNotificationData.fromArrays(listOf(reminderAId), listOf(eventA.reminderEventId), Instant.now(), -1) + val dataB = ReminderNotificationData.fromArrays(listOf(reminderBId), listOf(eventB.reminderEventId), Instant.now(), -1) + + val scenario = androidx.test.core.app.ActivityScenario.launch( + ReminderAlarmActivity.getIntent(targetContext, dataA) + ) + + try { + // First alarm shows its own data. + assertTrue( + pollUntil(10_000) { notificationTitleText()?.contains("Meds A") == true }, + "Alarm screen should show the first alarm's medicine (Meds A), got: ${notificationTitleText()}" + ) + + // A second alarm intent arrives while the activity is on top (singleTop redelivery). + // Instrumentation.callActivityOnNewIntent is the exact entry point the framework + // uses (ActivityThread -> performNewIntent -> onNewIntent), so this exercises the + // real delivery path instead of a reflection shortcut. + scenario.onActivity { activity -> + InstrumentationRegistry.getInstrumentation() + .callActivityOnNewIntent(activity, ReminderAlarmActivity.getIntent(targetContext, dataB)) + } + + // The screen must now show the SECOND alarm's medicine, not the previous one. + assertTrue( + pollUntil(10_000) { notificationTitleText()?.contains("Meds B") == true }, + "Alarm screen should show the second alarm's medicine (Meds B), got: ${notificationTitleText()}" + ) + } finally { + scenario.close() + } + } + + private fun notificationTitleText(): String? { + var text: String? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + text = (ActivityLifecycleMonitorRegistry.getInstance() + .getActivitiesInStage(Stage.RESUMED) + .firstOrNull { it is ReminderAlarmActivity } as Activity?) + ?.findViewById(com.futsch1.medtimer.feature.reminders.R.id.notificationTitle) + ?.text + ?.toString() + } + return text + } +} diff --git a/app/src/androidTest/java/com/futsch1/medtimer/harness/RepositoryEntryPoint.kt b/app/src/androidTest/java/com/futsch1/medtimer/harness/RepositoryEntryPoint.kt new file mode 100644 index 000000000..562efcc17 --- /dev/null +++ b/app/src/androidTest/java/com/futsch1/medtimer/harness/RepositoryEntryPoint.kt @@ -0,0 +1,17 @@ +package com.futsch1.medtimer.harness + +import com.futsch1.medtimer.core.domain.repository.MedicineRepository +import com.futsch1.medtimer.core.domain.repository.ReminderEventRepository +import com.futsch1.medtimer.core.domain.repository.ReminderRepository +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** Test-only access to the repositories, for seeding data directly instead of via the UI. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface RepositoryEntryPoint { + fun medicineRepository(): MedicineRepository + fun reminderRepository(): ReminderRepository + fun reminderEventRepository(): ReminderEventRepository +} diff --git a/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/AlarmFragment.kt b/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/AlarmFragment.kt index dc5c3cbcf..262f0da1a 100644 --- a/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/AlarmFragment.kt +++ b/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/AlarmFragment.kt @@ -134,12 +134,18 @@ class AlarmFragment( private fun closeWithIntent(pendingIntent: PendingIntent) { pendingIntent.send() - requireActivity().supportFragmentManager.beginTransaction().remove(this).commit() + requireActivity().finishAndRemoveTask() } override fun onDestroyView() { super.onDestroyView() - Log.d(ALARM, "Closing activity") - requireActivity().finishAndRemoveTask() + Log.d(ALARM, "Alarm fragment view destroyed") + // Keep the alarm task out of recents when the alarm closes via a path other than the + // action buttons (e.g. system back), while allowing onNewIntent to replace this fragment. + // This preserves the pre-existing behavior from 1fd00fa9 ("Fix alarm activity being able + // to be restarted"): isFinishing is false during a fragment replacement, true on a real close. + if (requireActivity().isFinishing || requireActivity().isDestroyed) { + requireActivity().finishAndRemoveTask() + } } } \ No newline at end of file diff --git a/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/ReminderAlarmActivity.kt b/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/ReminderAlarmActivity.kt index 63ebf0002..d0beef972 100644 --- a/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/ReminderAlarmActivity.kt +++ b/feature/reminders/src/main/java/com/futsch1/medtimer/feature/reminders/alarm/ReminderAlarmActivity.kt @@ -174,10 +174,22 @@ class ReminderAlarmActivity : AppCompatActivity() { if (intent != null) { Log.d(LogTags.ALARM, "Adding alarm fragment") supportFragmentManager.beginTransaction() - .add(R.id.alarmFragmentContainer, AlarmFragment::class.java, intent.extras).commit() + .replace(R.id.alarmFragmentContainer, AlarmFragment::class.java, intent.extras).commit() } } + /** + * The alarm activity is launched with FLAG_ACTIVITY_SINGLE_TOP, so a second alarm + * arriving while it is already on top is delivered here instead of recreating the + * activity. Without this, the new alarm's events would be dropped and the screen + * would keep showing the previous alarm (see issue #1494). + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + addAlarmFragment(intent) + } + companion object { fun getIntent( context: Context,